From 8d859a307786845137366569bfd192a87a18244a Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:57:28 -0600 Subject: [PATCH 01/16] build: produce static ARMv7 recovery tools Select ARM/v7 or ARM64 tool recipes explicitly and keep their outputs in separate caches. Pin Debian's ARMHF BusyBox package and verify each finished tool's ELF architecture and static linkage. Native builds require a host whose architecture matches the selected target. Assisted-by: LLM --- boot/initramfs/recovery/build-tools | 29 ++++-- .../recovery/build-tools-in-container | 13 ++- boot/initramfs/recovery/check-tools.py | 77 +++++++++++++++ boot/initramfs/recovery/install-toolchain | 2 +- boot/initramfs/recovery/upstream.lock | 4 + src/orchard/buildenv.py | 31 ++++--- tests/test_recovery_tools_arch.py | 93 +++++++++++++++++++ 7 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 boot/initramfs/recovery/check-tools.py create mode 100644 tests/test_recovery_tools_arch.py 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/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/src/orchard/buildenv.py b/src/orchard/buildenv.py index 332c7e04..a4c1d763 100644 --- a/src/orchard/buildenv.py +++ b/src/orchard/buildenv.py @@ -2,7 +2,7 @@ Recipes address inputs through ORCHARD_* path variables. Native mode provides real paths; local mode provides the corresponding container mount paths. Native -mode is not a sandbox and is only for trusted, disposable Linux ARM64 workers. +mode is not a sandbox and is only for trusted, disposable Linux ARM workers. """ import argparse import os @@ -17,8 +17,8 @@ def native(): if value not in ('0', '1'): raise ValueError('ORCHARD_BUILD_NATIVE must be 0 or 1') if value == '1': - if platform.system() != 'Linux' or platform.machine() not in ('aarch64', 'arm64'): - raise ValueError('Native builds require Linux ARM64') + if platform.system() != 'Linux' or platform.machine().lower() not in ('aarch64', 'arm64', 'armv7l', 'armv7', 'armhf'): + raise ValueError('Native builds require Linux ARM64 or ARMv7') if not os.environ.get('ORCHARD_BUILD_ENV_ID'): raise ValueError('Native builds require ORCHARD_BUILD_ENV_ID') return True @@ -26,20 +26,31 @@ def native(): # Podman's names for host machines it can build for. -HOST_ARCHES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64'} +HOST_ARCHES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64', + 'armv7l': 'arm', 'armv7': 'arm', 'armhf': 'arm'} + + +def architecture_arguments(arch): + # OCI's ARM architecture needs a variant; plain arm can resolve to ARMv5. + return ['--platform', 'linux/arm/v7'] if arch == 'arm' else (['--arch', arch] if arch else []) + + +def check_native_architecture(arch): + if arch is not None and HOST_ARCHES.get(platform.machine().lower()) != arch: + raise ValueError(f'Native target {arch} differs from host architecture ' + f'{HOST_ARCHES.get(platform.machine().lower(), platform.machine())}') def build(image, containerfile, context, arch=None): if native(): - if arch not in (None, 'arm64'): - raise ValueError('Native builds require ARM64 inputs') + check_native_architecture(arch) print('Using provisioned native environment: ' + identity(image), flush=True) else: # Always name the architecture: recipes share base images such as # debian:bookworm, and an arm64 recipe re-points that local tag, which # a host-architecture build would otherwise silently inherit. arch = arch or HOST_ARCHES.get(platform.machine().lower()) - argv = ['podman', 'build'] + (['--arch', arch] if arch else []) + argv = ['podman', 'build', *architecture_arguments(arch)] subprocess.run([*argv, '-f', str(containerfile), '-t', image, str(context)], check=True) @@ -63,8 +74,7 @@ def translate(value, mounts): def run(image, command, mounts=(), *, env=None, cwd=None, root=False, network=False, arch=None, memory=None): """MEMORY: keep ORCHARD_WORK in memory of this size where a container runs it (e.g. '12g').""" if native(): - if arch not in (None, 'arm64'): - raise ValueError('Native builds require ARM64 inputs') + check_native_architecture(arch) with tempfile.TemporaryDirectory(prefix='orchard-recipe-') as temporary: work, tmp = Path(temporary) / 'work', Path(temporary) / 'tmp' work.mkdir() @@ -77,8 +87,7 @@ def run(image, command, mounts=(), *, env=None, cwd=None, root=False, network=Fa env={**os.environ, **paths, **extra}, check=True) else: argv = ['podman', 'run', '--rm', '--userns', 'keep-id:uid=0,gid=0' if root else 'keep-id'] - if arch: - argv += ['--arch', arch] + argv += architecture_arguments(arch) if not network: argv += ['--network', 'none'] if memory: diff --git a/tests/test_recovery_tools_arch.py b/tests/test_recovery_tools_arch.py new file mode 100644 index 00000000..8277841f --- /dev/null +++ b/tests/test_recovery_tools_arch.py @@ -0,0 +1,93 @@ +"""Recovery tools must match their target ISA before entering an image.""" +import importlib.util +import json +import os +from pathlib import Path +import struct +import unittest +from unittest import mock + +from orchard import buildenv + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('check_recovery_tools', + ROOT / 'boot/initramfs/recovery/check-tools.py') +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +def elf(arch, *, kind=1, dynamic_tag=None, flags=0x05000400): + arm = arch == 'arm' + header_size, ph_size = (52, 32) if arm else (64, 56) + ident = b'\x7fELF' + bytes((1 if arm else 2, 1, 1)) + bytes(9) + dynamic = struct.pack(' Date: Fri, 6 Nov 2026 11:57:46 -0600 Subject: [PATCH 02/16] boot: build Cascadia payloads from source Use board-owned handoff data to compile the ARM32 staging loader. Preserve Cascadia's cache/MMU handoff and require executable code at the upload address. A checked configuration places the supplied external initramfs in reserved RAM before entering Linux through its edited DTB. Bound the zImage decompressor workspace and preserve the kernel bytes. Add synthetic layout tests and a Linux fixture that exercises the actual loader C copy and rejection paths. Source provenance is recorded in NOTICE. The external-initramfs handoff still requires physical qualification. Assisted-by: LLM --- boot/cascadia/NOTICE | 14 ++ boot/cascadia/README.md | 37 +++ boot/cascadia/build | 50 ++++ boot/cascadia/compile | 14 ++ boot/cascadia/linux-boot.ld.in | 22 ++ boot/cascadia/linux_boot.c | 87 +++++++ boot/cascadia/linux_boot_start.S | 135 ++++++++++ chips/s5l8942x.toml | 1 + devices/p105/README.md | 11 + devices/p105/cascadia.toml | 12 + devices/p105/device.toml | 1 + src/orchard/hardware.py | 10 + src/orchard/image/cascadia.py | 393 ++++++++++++++++++++++++++++++ tests/fixtures/cascadia-runtime.c | 101 ++++++++ tests/test_cascadia_loader.py | 239 ++++++++++++++++++ 15 files changed, 1127 insertions(+) create mode 100644 boot/cascadia/NOTICE create mode 100644 boot/cascadia/README.md create mode 100755 boot/cascadia/build create mode 100755 boot/cascadia/compile create mode 100644 boot/cascadia/linux-boot.ld.in create mode 100644 boot/cascadia/linux_boot.c create mode 100644 boot/cascadia/linux_boot_start.S create mode 100644 devices/p105/cascadia.toml create mode 100644 src/orchard/image/cascadia.py create mode 100644 tests/fixtures/cascadia-runtime.c create mode 100644 tests/test_cascadia_loader.py 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..2a5eccd6 --- /dev/null +++ b/boot/cascadia/README.md @@ -0,0 +1,37 @@ +# 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 loader validates its configuration checksum and bounds +before copying the initramfs to the profile's reserved high-memory range. +It then performs the Cascadia cache/MMU handoff with the DTB in `r2`. +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..10e647c6 --- /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.image.cascadia 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..16526a73 --- /dev/null +++ b/boot/cascadia/linux_boot.c @@ -0,0 +1,87 @@ +// 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, *from; + volatile u8 *to; + u32 i; + + 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 >= 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; + if (WATCHDOG_ADDRESS) { + volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; + for (i = 0; i < 4; i++) + wdt[i] = 0; + } + from = (volatile const u8 *)(LOAD_ADDRESS + c->initrd_offset); + to = (volatile u8 *)INITRD_ADDRESS; + for (i = 0; i < c->initrd_size; i++) + to[i] = from[i]; + return LOAD_ADDRESS + c->dtb_offset; +} diff --git a/boot/cascadia/linux_boot_start.S b/boot/cascadia/linux_boot_start.S new file mode 100644 index 00000000..1052e304 --- /dev/null +++ b/boot/cascadia/linux_boot_start.S @@ -0,0 +1,135 @@ +/* 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 + + 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 */ + + 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 + + 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/chips/s5l8942x.toml b/chips/s5l8942x.toml index 50ba0c2d..1df5e145 100644 --- a/chips/s5l8942x.toml +++ b/chips/s5l8942x.toml @@ -14,3 +14,4 @@ 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" 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/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..bd06785c 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -13,6 +13,7 @@ chip = 0x8942 board = 0x0a [boot] +# Source-built Cascadia payload profile: cascadia.toml. External-initramfs handoff awaits testing. methods = ["jailbreak-kdfu", "recovery-autogo"] entries = ["pico-checkm8", "kdfu"] # The CLI consumes an owner-private boot manifest. Public launch assets await release integration. diff --git a/src/orchard/hardware.py b/src/orchard/hardware.py index e4d5b010..9eea2e88 100644 --- a/src/orchard/hardware.py +++ b/src/orchard/hardware.py @@ -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' diff --git a/src/orchard/image/cascadia.py b/src/orchard/image/cascadia.py new file mode 100644 index 00000000..6b02caff --- /dev/null +++ b/src/orchard/image/cascadia.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Assemble an ARM32 iBEC payload with an external initramfs described by its DTB. + +The loader occupies the first 32 KiB. Its checked configuration tells the +loader where to copy the initramfs before entering the unchanged zImage. +This module uses only the standard library and also runs by file path. +""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +import tomllib +import zlib + + +CONFIG_OFFSET = 0x7f00 +CONFIG = struct.Struct('<4s16I') +CONFIG_FIELDS = ('magic', 'version', 'header_size', 'load_address', 'kernel_offset', + 'dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', + 'initrd_address', 'image_size', 'watchdog_address', 'l2_address', + 'ram_start', 'ram_end', 'initrd_limit', 'crc32') +PROFILE_FIELDS = frozenset(('schema', 'load_address', 'kernel_offset', 'watchdog_address', + 'l2_address', 'ram_start', 'ram_end', 'initrd_address', 'initrd_limit')) +FDT_HEADER = struct.Struct('>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 > 0x6000: + 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) > 2047: + 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 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): + 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') + 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 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('--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) + 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/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c new file mode 100644 index 00000000..b3f276e9 --- /dev/null +++ b/tests/fixtures/cascadia-runtime.c @@ -0,0 +1,101 @@ +/* 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] == 0xa5); +} + +int main(void) +{ + CHECK(sizeof(u32) == 4 && sizeof(struct config) == 68); + memory(LOAD_ADDRESS, 0x20000); + memory(INITRD_ADDRESS, 0x10000); + memory(WATCHDOG_ADDRESS & ~4095u, 4096); + prepare(); + CHECK(cascadia_main() == LOAD_ADDRESS + 0x9000); + 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); + + /* 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(); + ((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/test_cascadia_loader.py b/tests/test_cascadia_loader.py new file mode 100644 index 00000000..7f8bae26 --- /dev/null +++ b/tests/test_cascadia_loader.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: GPL-2.0-only +import json +from pathlib import Path +import struct +import shutil +import subprocess +import sys +import tempfile +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +from orchard.image import cascadia + + +PROFILE = dict(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) + + +def loader(described=PROFILE): + record = dict(magic=b'CB32', version=1, header_size=cascadia.CONFIG.size, + dtb_offset=0, dtb_size=0, initrd_offset=0, initrd_size=0, + image_size=0, crc32=0) + record.update({key: value for key, value in described.items() if key != 'schema'}) + raw = bytearray(cascadia.CONFIG_OFFSET + cascadia.CONFIG.size) + raw[:4] = bytes.fromhex('c0000cf1') + raw[cascadia.CONFIG_OFFSET:] = cascadia.CONFIG.pack(*(record[key] for key in cascadia.CONFIG_FIELDS)) + return bytes(raw) + + +def executable(raw, described=PROFILE): + data = bytearray(0x300 + 3 * 40) + data[:7] = b'\x7fELF\x01\x01\x01' + 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(' Date: Fri, 6 Nov 2026 11:57:46 -0600 Subject: [PATCH 03/16] build: resolve ARMv7 recovery and distribution inputs Resolve one root architecture's per-platform loader and target tools through Orchard's producer contract. Preserve explicit board selection for Cascadia and stage ARM EABI5 hard-float Python dependency closures. Platform layers use the declared kernel image and validate the requested root ISA. Route RAM and recovery assembly through the declared payload format, bind Cascadia DTBs to their selected board, and keep Pongo inputs on their own boot path. Existing ARM64 default arguments remain supported. Document the producer contract and exercise cache separation and architecture refusal. Assisted-by: LLM --- docs/distribution-contract.md | 42 ++++- platforms/apple-armv7.toml | 5 + src/orchard/build.py | 59 ++++-- src/orchard/command.py | 8 +- src/orchard/distro.py | 18 +- src/orchard/image/elf.py | 105 +++++++++-- src/orchard/image/platform.py | 63 +++++-- src/orchard/image/prepare_runtime.py | 24 ++- src/orchard/image/recovery.py | 30 ++- src/orchard/inputs.py | 109 ++++++++--- src/orchard/project.py | 1 + src/orchard/ram.py | 76 ++++++-- tests/hardware_layout.py | 2 +- tests/test_armv7_producers.py | 263 +++++++++++++++++++++++++++ tests/test_distribution_contract.py | 8 +- tests/test_platform_layer.py | 3 +- tests/test_recovery_tools_arch.py | 56 +++++- 17 files changed, 764 insertions(+), 108 deletions(-) create mode 100644 tests/test_armv7_producers.py 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/platforms/apple-armv7.toml b/platforms/apple-armv7.toml index 61510ff2..05dba21e 100644 --- a/platforms/apple-armv7.toml +++ b/platforms/apple-armv7.toml @@ -15,3 +15,8 @@ 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 = [] diff --git a/src/orchard/build.py b/src/orchard/build.py index b57711c5..a11eeecc 100644 --- a/src/orchard/build.py +++ b/src/orchard/build.py @@ -491,7 +491,7 @@ def has_loader_patches(hardware): M1N1_INPUTS = ['boot/m1n1', 'boot/quiet/m1n1.patch', 'src/orchard/buildenv.py'] TOOL_INPUTS = ['boot/initramfs/recovery/' + name for name in ('build-tools', 'build-tools-in-container', 'Containerfile', - 'install-toolchain', 'upstream.lock', 'orchard-write.c')] + ['src/orchard/buildenv.py'] + 'install-toolchain', 'upstream.lock', 'check-tools.py', 'orchard-write.c')] + ['src/orchard/buildenv.py'] def recipe_work(components, recipe, key): @@ -508,30 +508,62 @@ def recipe_work(components, recipe, key): return work -def _prepare_tools(hardware, components): +def _prepare_tools(hardware, components, architecture='arm64'): """The hardware's static recovery tools (BusyBox, Dropbear, Wi-Fi tools). Caller holds the lock.""" + if architecture not in ('arm', 'arm64'): + raise ValueError('Unsupported recovery architecture: ' + architecture) + name = 'recovery-tools' if architecture == 'arm64' else 'recovery-tools-' + architecture + key = recipe_key(hardware, TOOL_INPUTS) + if architecture != 'arm64': + key = hashlib.sha256((key + ':' + architecture).encode()).hexdigest() def produce_tools(stage): - work = recipe_work(components, 'recovery-tools', recipe_key(hardware, TOOL_INPUTS)) + work = recipe_work(components, name, key) if not (work / 'bin/busybox').exists(): - command(hardware / 'boot/initramfs/recovery/build-tools', + command(hardware / 'boot/initramfs/recovery/build-tools', '--arch', architecture, env={**os.environ, 'OMA_RECOVERY_TOOLS_WORK': str(work)}) + command(sys.executable, hardware / 'boot/initramfs/recovery/check-tools.py', + '--arch', architecture, *(work / 'bin' / name for name in + ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw', + 'orchard-write', 'resize2fs', 'tune2fs'))) for tool in (work / 'bin').iterdir(): if tool.is_file() and not tool.is_symlink(): shutil.copyfile(tool, stage / tool.name) (stage / tool.name).chmod(0o755) - return cached_component(components, 'recovery-tools', - lambda: recipe_key(hardware, TOOL_INPUTS), produce_tools) + return cached_component(components, name, lambda: key, produce_tools) def prepare_tools(hardware=None, cache=None, *, device): """The directory of static recovery tools for RAM images, built or reused.""" from . import published hardware, cache = _build_context(hardware, cache, device) - tools = published.tools(hardware, cache) + architecture = kernels.descriptor(hardware, device)['arch'] + tools = published.tools(hardware, cache) if architecture == 'arm64' else None if tools is not None: return tools with component_lock(cache): - return _prepare_tools(hardware, cache / 'components-v2') + if architecture == 'arm64': + return _prepare_tools(hardware, cache / 'components-v2') + return _prepare_tools(hardware, cache / 'components-v2', architecture) + + +def prepare_payload_loader(hardware=None, cache=None, *, device): + """Build the loader format named by the device's platform descriptor.""" + hardware, cache = _build_context(hardware, cache, device) + kind = kernels.descriptor(hardware, device)['loader'] + components = cache / 'components-v2' + with component_lock(cache): + if kind == 'm1n1': + return _prepare_m1n1(hardware, components, 'apple') + if kind != 'cascadia': + raise ValueError('Unsupported payload loader: ' + kind) + from .hardware import payload_profile + profile = payload_profile(device, 'cascadia', hardware) + inputs = ['boot/cascadia', 'src/orchard/image/cascadia.py', str(profile.relative_to(hardware)), + 'src/orchard/buildenv.py'] + def produce(stage): + command(hardware / 'boot/cascadia/build', '--profile', profile, '--output', stage) + return cached_component(components, 'loader-cascadia-' + device, + lambda: recipe_key(hardware, inputs), produce) / 'loader.bin' RELEASES = 'https://github.com/OrchardLinux/orchard/releases/download' @@ -630,19 +662,24 @@ def prepare_components(hardware=None, cache=None, *, device, kernel=None, if kernel is not None and kernel_tree is not None: raise ValueError('Give a staged kernel (--kernel-dir) or a kernel tree (--kernel-tree), not both') tree = kernels.check_tree(hardware, device, kernel_tree) if kernel_tree is not None else None + described = kernels.descriptor(hardware, device) + if pongo is not None and described['loader'] == 'cascadia': + raise ValueError('Cascadia payloads use recovery-autogo firmware stages') + if loader is None and described['loader'] == 'cascadia': + loader = prepare_payload_loader(hardware, cache, device=device) if loader is None and not has_loader_patches(hardware): raise ValueError(f'Missing loader patches for {device}; add a loader recipe or supply --loader FILE') with component_lock(cache): - if pongo is None: + if pongo is None and described['loader'] == 'm1n1': pongo = _prepare_pongo(hardware, components) if kernel is None: kernel = _prepare_kernel(hardware, cache, device, tree) if busybox is None: - busybox = _prepare_tools(hardware, components) / 'busybox' + busybox = _prepare_tools(hardware, components, described['arch']) / 'busybox' if loader is None: # One m1n1 starts every Apple device; the board only picks its DTB. loader = _prepare_m1n1(hardware, components, 'apple') - return {name: Path(path).resolve() for name, path in + return {name: Path(path).resolve() if path is not None else None for name, path in dict(kernel=kernel, busybox=busybox, loader=loader, pongo=pongo).items()} diff --git a/src/orchard/command.py b/src/orchard/command.py index 0a49f93e..dc83ca93 100644 --- a/src/orchard/command.py +++ b/src/orchard/command.py @@ -138,8 +138,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='') 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/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..576b068c 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 @@ -117,6 +127,7 @@ def main(): 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') args = parser.parse_args() root = args.output if root.exists(): @@ -129,7 +140,10 @@ 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 diff --git a/src/orchard/image/recovery.py b/src/orchard/image/recovery.py index 7c2ee440..87babe68 100644 --- a/src/orchard/image/recovery.py +++ b/src/orchard/image/recovery.py @@ -19,10 +19,35 @@ 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 + 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 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 +64,14 @@ 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'], '--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 []), '--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/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..c24c47dd 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/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..166a8135 --- /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(' Date: Fri, 6 Nov 2026 14:23:40 -0600 Subject: [PATCH 04/16] recovery: bind ARMv7 RAM sessions to guarded host identity Carry a descriptor-selected host USB identity policy through the checked Cascadia bootargs, bind each upload to its selected hardware and a fresh attempt, and revalidate that provenance in RAM. ARM64 recovery keeps its existing ADT identity path. Stage declared USB dependencies and defer manual storage modules until the host has serial and authenticated SSH. Reject manual drivers built into the kernel or selected as USB dependencies before assembly. Validated with 666 unit tests (one Linux-only skip) and independent cross-layer review. Physical P105 qualification remains pending. Assisted-by: LLM --- boot/initramfs/recovery/init | 31 ++-- boot/initramfs/recovery/usb-gadget | 7 +- chips/s5l8942x.toml | 3 + devices/p105/device.toml | 1 + docs/boot-contract.md | 47 +++++ platforms/apple-armv7.toml | 9 + runtime/recovery/device.py | 3 + runtime/recovery/identity.py | 141 ++++++++++++--- runtime/recovery/policy.py | 55 ++++++ src/orchard/image/cascadia.py | 103 ++++++++++- src/orchard/image/prepare_runtime.py | 38 ++++ src/orchard/image/recovery.py | 8 + src/orchard/image/runtime.py | 3 +- src/orchard/recovery_identity.py | 124 ++++++++++++++ src/orchard/recovery_policy.py | 35 ++++ tests/test_host_identity_handoff.py | 248 +++++++++++++++++++++++++++ tests/test_public_recovery.py | 134 +++++++++++++++ 17 files changed, 953 insertions(+), 37 deletions(-) create mode 100644 runtime/recovery/policy.py create mode 100644 src/orchard/recovery_identity.py create mode 100644 src/orchard/recovery_policy.py create mode 100644 tests/test_host_identity_handoff.py create mode 100644 tests/test_public_recovery.py 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/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/chips/s5l8942x.toml b/chips/s5l8942x.toml index 1df5e145..32a4fc52 100644 --- a/chips/s5l8942x.toml +++ b/chips/s5l8942x.toml @@ -15,3 +15,6 @@ 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" + +# 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/device.toml b/devices/p105/device.toml index bd06785c..95a55378 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -13,6 +13,7 @@ chip = 0x8942 board = 0x0a [boot] +# 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"] entries = ["pico-checkm8", "kdfu"] diff --git a/docs/boot-contract.md b/docs/boot-contract.md index aa7c72fc..290d9bec 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -199,3 +199,50 @@ 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). + +## Host-bound ARMv7 recovery identity + +Cascadia recovery templates carry a descriptor-selected identity policy in the +standard `/chosen/bootargs` string. These are userland parameters, not hardware +register descriptions. `orchard.recovery-policy=VALUE` and +`orchard.recovery-handoff=VALUE` encode schema-1 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 `source = "host-usb"`, canonical `profile`, accepted live FDT +`compatibles`, and integer `chip` and `board` identifiers. The public runtime +contains the same policy, generated from the selected hardware descriptor. +A 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 then binds one nonzero random 128-bit +`handoff_id` to that observation and the original `template_sha256`. The handoff +also contains `schema`, `source`, `profile`, `ecid`, `chip` and `board`. The +transport rebuilds the checked CB32 configuration and FDT while retaining the +kernel and initramfs bytes, then rechecks the selected endpoint immediately +before its single upload. Its private attempt receipt records both image +hashes and the nonce. + +The runtime requires the live compatible, policy token and staged policy to +agree. It publishes `identity_source = "host-usb"`, the nonce and original +image digest with the kernel's fresh boot ID. This records a host observation; +it does not claim that Linux read a SoC ECID register. USB serial includes +`HND:` followed by the nonce, alongside the canonical board, ECID and boot ID. +The host requires that nonce before accepting this boot, then checks the full +provenance again through serial SSH bootstrap and authenticated status. A +later command may resume only through the matching private completed attempt +receipt, including the same profile, ECID, nonce, boot ID and template digest. +An unknown or changed handoff stops before a storage operation. + +The existing ARM64 path requires its live ADT MTD identity source. Absence of a +host-bound policy preserves that path; an invalid host-bound policy or missing +handoff never falls back to ADT. This handoff currently serves the guarded +host RAM-boot path. On-device startup needs its own checked identity source +before it can use this public recovery runtime. + +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/platforms/apple-armv7.toml b/platforms/apple-armv7.toml index 05dba21e..eb21cb70 100644 --- a/platforms/apple-armv7.toml +++ b/platforms/apple-armv7.toml @@ -20,3 +20,12 @@ loader = "cascadia" # the payload format that boots this kernel # 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..a1954aa1 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') == 'host-usb' 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..ca91d859 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 the live ADT identity or a descriptor-bound host USB boot handoff.""" import json import os from pathlib import Path @@ -8,15 +8,93 @@ from adt import validate +try: + from recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, object_json, + parameters, validate_policy) +except ModuleNotFoundError as error: + if error.name != 'recovery_identity': + raise + from orchard.recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, 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 _host_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 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': 'host-usb', '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 +108,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 = _host_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 +128,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 = _host_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 +159,14 @@ def initialize(): return result +def serial(record): + result = f'ECID:{record["ecid"]:016x} BOOT:{record["boot_id"]} BOARD:{record["profile"]}' + if record.get('identity_source') == 'host-usb': + result += ' HND:' + record['handoff_id'] + 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/image/cascadia.py b/src/orchard/image/cascadia.py index 6b02caff..d3c899de 100644 --- a/src/orchard/image/cascadia.py +++ b/src/orchard/image/cascadia.py @@ -11,9 +11,16 @@ import json from pathlib import Path import struct +import sys import tomllib import zlib +if __package__: + from .. import recovery_identity as identity_protocol +else: # The distribution contract also runs this producer by file path. + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + import recovery_identity as identity_protocol + CONFIG_OFFSET = 0x7f00 CONFIG = struct.Struct('<4s16I') @@ -241,7 +248,7 @@ 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) > 2047: + 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)) @@ -267,6 +274,16 @@ def chosen(data, bootargs, 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. @@ -301,7 +318,7 @@ def decompression_bound(kernel, dtb_size, described): return bound -def assemble(loader, kernel, dtb, initramfs, described, bootargs, *, elf=None): +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'] @@ -348,6 +371,77 @@ def append(name, payload): '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.""" + policy = host_identity_policy(data) + if policy is None: + raise ValueError('Payload does not declare host-bound recovery identity') + 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': 'host-usb', + '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) @@ -356,6 +450,7 @@ def main(): 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() @@ -376,7 +471,9 @@ def main(): 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) + 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()) diff --git a/src/orchard/image/prepare_runtime.py b/src/orchard/image/prepare_runtime.py index 576b068c..7bc79ac1 100644 --- a/src/orchard/image/prepare_runtime.py +++ b/src/orchard/image/prepare_runtime.py @@ -122,13 +122,38 @@ def stage_runtime(base, receipt, root, architecture='arm64'): 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') @@ -150,6 +175,8 @@ def main(): 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. @@ -165,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 87babe68..70d84a01 100644 --- a/src/orchard/image/recovery.py +++ b/src/orchard/image/recovery.py @@ -29,6 +29,11 @@ def main(): 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') @@ -65,6 +70,7 @@ def main(): '--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'), @@ -72,6 +78,8 @@ def main(): 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/recovery_identity.py b/src/orchard/recovery_identity.py new file mode 100644 index 00000000..91c56bb0 --- /dev/null +++ b/src/orchard/recovery_identity.py @@ -0,0 +1,124 @@ +"""Bounded host USB 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')) +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): + if (not isinstance(result, dict) or set(result) != fields or + type(result.get('schema')) is not int or result['schema'] != 1 or + result.get('source') != 'host-usb' 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): + _selected(result, POLICY_FIELDS) + 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 validate_handoff(result): + _selected(result, HANDOFF_FIELDS) + 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..065cdc81 --- /dev/null +++ b/src/orchard/recovery_policy.py @@ -0,0 +1,35 @@ +"""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, 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 = validate_policy({'schema': 1, 'source': 'host-usb', 'profile': selected['device']['profile'], + 'compatibles': selected['device']['dt_compatible'], 'chip': selected['match']['dfu']['chip'], + 'board': selected['match']['dfu']['board']}) + 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/tests/test_host_identity_handoff.py b/tests/test_host_identity_handoff.py new file mode 100644 index 00000000..2a3e47c4 --- /dev/null +++ b/tests/test_host_identity_handoff.py @@ -0,0 +1,248 @@ +"""Descriptor-bound public recovery identity; fixtures never access a device.""" +import base64 +import importlib.util +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest import mock + +from orchard.apple import adt +from orchard import recovery_identity as codec + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('host_identity_fixture', ROOT/'runtime/recovery/identity.py') +identity = importlib.util.module_from_spec(spec) +with mock.patch.dict(sys.modules, {'adt': adt}): + spec.loader.exec_module(identity) +BOOT = '11111111-1111-4111-8111-111111111111' +POLICY = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', + 'compatibles': ['apple,p105ap'], 'chip': 0x8942, 'board': 0x0a} +HANDOFF = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', 'ecid': 123, + 'chip': 0x8942, 'board': 0x0a, 'handoff_id': 'a'*32, 'template_sha256': 'b'*64} + + +def raw_token(name, raw): + return name + '=' + base64.urlsafe_b64encode(raw).rstrip(b'=').decode() + + +class IdentityCodec(unittest.TestCase): + def test_roundtrip_partial_and_complete_parameters(self): + policy = codec.encode_parameter(codec.POLICY_PARAMETER, POLICY) + handoff = codec.encode_parameter(codec.HANDOFF_PARAMETER, HANDOFF) + self.assertEqual(codec.parameters('console=tty0'), {}) + self.assertEqual(codec.parameters('console=tty0 ' + policy), {codec.POLICY_PARAMETER: POLICY}) + self.assertEqual(codec.parameters((policy+' '+handoff+'\n').encode()), + {codec.POLICY_PARAMETER: POLICY, codec.HANDOFF_PARAMETER: HANDOFF}) + + def test_arm_command_line_boundary_and_truncated_handoff(self): + policy = codec.encode_parameter(codec.POLICY_PARAMETER, POLICY) + handoff = codec.encode_parameter(codec.HANDOFF_PARAMETER, HANDOFF) + tokens = policy + ' ' + handoff + full = 'x' * (1023-len(tokens)-1) + ' ' + tokens + self.assertEqual(len(full), 1023) + self.assertEqual(codec.parameters(full + '\n'), + {codec.POLICY_PARAMETER: POLICY, codec.HANDOFF_PARAMETER: HANDOFF}) + with self.assertRaisesRegex(ValueError, 'bound'): + codec.parameters('x' + full) + truncated = ('x' * (1024-len(handoff)-1) + ' ' + handoff)[:1023] + self.assertEqual(len(truncated), 1023) + with self.assertRaises(ValueError): + codec.parameters(truncated) + + def test_ambiguous_malformed_and_oversized_tokens_fail(self): + policy = codec.encode_parameter(codec.POLICY_PARAMETER, POLICY) + handoff = codec.encode_parameter(codec.HANDOFF_PARAMETER, HANDOFF) + duplicate = json.dumps(HANDOFF).replace('"ecid": 123', '"ecid": 0, "ecid": 123').encode() + for line in (policy+' '+policy, handoff+' '+handoff, codec.HANDOFF_PARAMETER, + policy+'=', codec.HANDOFF_PARAMETER+'=!', + raw_token(codec.HANDOFF_PARAMETER, duplicate), + raw_token(codec.HANDOFF_PARAMETER, b'{}\0'), + raw_token(codec.HANDOFF_PARAMETER, b'[]'), + 'orchard.recovery-unknown=e30', + handoff+'\0', handoff+'\r', handoff+'\n\n', 'x'*1024): + with self.subTest(line=line[:90]), self.assertRaises(ValueError): + codec.parameters(line) + # Equivalent decoded bytes with nonzero unused pad bits are rejected. + with self.assertRaisesRegex(ValueError, 'canonical'): + codec.parameters(codec.HANDOFF_PARAMETER+'=e31') + + def test_selected_fields_and_attempt_types_are_exact(self): + changes = ({'schema': True}, {'schema': 2}, {'source': 'configuration'}, {'profile': ''}, + {'ecid': True}, {'ecid': 0}, {'ecid': 2**64}, {'chip': True}, {'board': False}, + {'handoff_id': '0'*32}, {'handoff_id': 'A'*32}, {'handoff_id': 'a'*31}, + {'template_sha256': 'x'*64}, {'extra': 1}) + for change in changes: + with self.subTest(change=change), self.assertRaises(ValueError): + codec.encode_parameter(codec.HANDOFF_PARAMETER, {**HANDOFF, **change}) + for change in ({'compatibles': []}, {'compatibles': ['apple,p105ap']*2}, + {'compatibles': [None]}, {'compatibles': ['other,p105']}, + {'board': -1}, {'chip': 0}, {'schema': True}, {'extra': 1}): + with self.subTest(change=change), self.assertRaises(ValueError): + codec.encode_parameter(codec.POLICY_PARAMETER, {**POLICY, **change}) + + +class HostIdentity(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + for name, relative in (('RUN', 'run'), ('DEV', 'dev'), ('MTD', 'mtd'), + ('BOOT_ID', 'boot-id'), ('COMPATIBLE', 'compatible'), + ('POLICY', 'policy'), ('CMDLINE', 'cmdline')): + patch = mock.patch.object(identity, name, self.root/relative) + patch.start() + self.addCleanup(patch.stop) + identity.BOOT_ID.write_text(BOOT) + identity.COMPATIBLE.write_bytes(b'apple,p105ap\0apple,s5l8942x\0') + identity.POLICY.write_text(json.dumps(POLICY, indent=2)) + identity.POLICY.chmod(0o644) + self.command_line() + original_lstat, original_fstat = Path.lstat, os.fstat + + def root_stat(value): + fields = list(value) + fields[4] = 0 # Emulate root-owned image files on a non-root build host. + return os.stat_result(fields) + + def lstat(path): + result = original_lstat(path) + if path == identity.POLICY or path == identity.RUN/'identity.json': + result = root_stat(result) + return result + + patch = mock.patch.object(Path, 'lstat', lstat) + patch.start() + self.addCleanup(patch.stop) + patch = mock.patch.object(os, 'fstat', lambda fd: root_stat(original_fstat(fd))) + patch.start() + self.addCleanup(patch.stop) + + def command_line(self, *, policy=POLICY, handoff=HANDOFF): + tokens = ['console=tty0'] + if policy is not None: + tokens.append(codec.encode_parameter(codec.POLICY_PARAMETER, policy)) + if handoff is not None: + tokens.append(codec.encode_parameter(codec.HANDOFF_PARAMETER, handoff)) + identity.CMDLINE.write_text(' '.join(tokens)+'\n') + + def test_canonical_profile_live_board_attempt_and_private_cache(self): + with mock.patch.object(identity, 'validate', side_effect=AssertionError('ADT fallback')): + record = identity.initialize() + self.assertEqual(record, {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, + 'identity_source': 'host-usb', 'handoff_id': 'a'*32, + 'template_sha256': 'b'*64, 'chip': 0x8942, 'board': 0x0a}) + self.assertEqual(identity.profile(), 'p105') + self.assertEqual(identity.cached(), record) + self.assertEqual(stat.S_IMODE((identity.RUN/'identity.json').stat().st_mode), 0o600) + serial = identity.serial(record) + self.assertTrue(serial.endswith(' BOARD:p105 HND:'+'a'*32)) + self.assertLessEqual(len(serial), 126) + with self.assertRaisesRegex(ValueError, 'already published'): + identity.initialize() + + def test_missing_handoff_or_policy_token_does_not_fall_back_to_adt(self): + for policy, handoff in ((POLICY, None), (None, HANDOFF), (None, None)): + self.command_line(policy=policy, handoff=handoff) + with self.subTest(policy=policy, handoff=handoff), \ + mock.patch.object(identity, 'validate', side_effect=AssertionError('ADT fallback')), \ + self.assertRaisesRegex(ValueError, 'policy and host USB handoff'): + identity.initialize() + self.assertFalse((identity.RUN/'identity.json').exists()) + + def test_descriptor_policy_and_live_first_compatible_must_match(self): + for raw in (b'apple,p105\0', b'apple,j1\0', b'apple,s5l8942x\0apple,p105ap\0', + b'apple,p105ap', b'apple,p105ap\0\0'): + identity.COMPATIBLE.write_bytes(raw) + with self.subTest(raw=raw), self.assertRaises(ValueError): + identity.initialize() + identity.COMPATIBLE.write_bytes(b'apple,p105ap\0') + self.command_line(policy={**POLICY, 'compatibles': ['apple,p105ap', 'apple,j1']}) + with self.assertRaisesRegex(ValueError, 'staged descriptor'): + identity.initialize() + for key, value in (('profile', 'j1'), ('chip', 0x8945), ('board', 1)): + self.command_line(handoff={**HANDOFF, key: value}) + with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'differs'): + identity.initialize() + + def test_policy_must_be_bounded_root_owned_and_not_writable_or_linked(self): + root_lstat = Path.lstat + + def unowned(path): + result = root_lstat(path) + if path == identity.POLICY: + fields = list(result) + fields[4] = 501 + result = os.stat_result(fields) + return result + + with mock.patch.object(Path, 'lstat', unowned), \ + self.assertRaisesRegex(ValueError, 'protected regular'): + identity.initialize() + identity.POLICY.chmod(0o664) + with self.assertRaisesRegex(ValueError, 'protected regular'): + identity.initialize() + identity.POLICY.chmod(0o644) + identity.POLICY.write_bytes(b'x'*2049) + with self.assertRaisesRegex(ValueError, 'protected regular'): + identity.initialize() + identity.POLICY.unlink() + identity.POLICY.symlink_to(self.root/'missing') + with self.assertRaisesRegex(ValueError, 'protected regular'): + identity.initialize() + + def test_cached_record_rechecks_attempt_digest_board_and_boot(self): + record = identity.initialize() + for key, value in (('ecid', 124), ('handoff_id', 'c'*32), ('template_sha256', 'd'*64)): + self.command_line(handoff={**HANDOFF, key: value}) + with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'differs'): + identity.cached() + self.command_line() + identity.BOOT_ID.write_text('22222222-2222-4222-8222-222222222222') + with self.assertRaisesRegex(ValueError, 'differs'): + identity.cached() + identity.BOOT_ID.write_text(BOOT) + cache = identity.RUN/'identity.json' + cache.write_text(json.dumps({**record, 'ecid': 123.0})) + with self.assertRaisesRegex(ValueError, 'differs'): + identity.cached() + cache.write_text(json.dumps(record).replace('"ecid": 123', '"ecid": 0, "ecid": 123')) + with self.assertRaisesRegex(ValueError, 'repeats'): + identity.cached() + cache.write_text(json.dumps(record)) + identity.CMDLINE.unlink() + with self.assertRaisesRegex(ValueError, 'unavailable'): + identity.cached() + + def test_usb_serial_bound_is_checked_before_publication(self): + policy = {**POLICY, 'profile': 'p'*32} + identity.POLICY.write_text(json.dumps(policy)) + self.command_line(policy=policy, handoff={**HANDOFF, 'profile': policy['profile']}) + with self.assertRaisesRegex(ValueError, 'USB serial string limit'): + identity.initialize() + self.assertFalse((identity.RUN/'identity.json').exists()) + + def test_no_policy_keeps_the_adt_requirement_even_with_handoff_tokens(self): + identity.POLICY.unlink() + identity.COMPATIBLE.write_bytes(b'apple,j71b\0') + identity.MTD.write_text('') + with self.assertRaisesRegex(ValueError, 'one live ADT'): + identity.initialize() + + def test_unknown_or_malformed_command_line_never_publishes(self): + valid = identity.CMDLINE.read_text() + values = (valid+'orchard.recovery-unknown=e30', valid+codec.encode_parameter(codec.HANDOFF_PARAMETER, HANDOFF), + valid.replace('orchard.recovery-handoff=', 'orchard.recovery-handoff=='), + valid+'\0', 'x'*1024) + for value in values: + identity.CMDLINE.write_text(value) + with self.subTest(value=value[:80]), self.assertRaises(ValueError): + identity.initialize() + self.assertFalse((identity.RUN/'identity.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_public_recovery.py b/tests/test_public_recovery.py new file mode 100644 index 00000000..3a484bb0 --- /dev/null +++ b/tests/test_public_recovery.py @@ -0,0 +1,134 @@ +"""The public Cascadia producer and identity handoff operate without a device.""" +import hashlib +import unittest + +from orchard import recovery_identity as protocol +from orchard.recovery_policy import settings +from orchard.image import cascadia +from test_cascadia_loader import PROFILE, loader, kernel, tree + +POLICY = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', + 'compatibles': ['apple,p105ap'], 'chip': 0x8942, 'board': 0x0a} + + +def payload(args='console=tty0 rdinit=/init', *, policy=POLICY, compatible=b'apple,p105ap\0apple,s5l8942x\0'): + root = tree() + root['properties']['compatible'] = compatible + return cascadia.assemble(loader(), kernel(), cascadia.write_fdt(root), b'public-initramfs', + PROFILE, args, identity_policy=policy)[0] + + +def bind(data, **changes): + values = dict(ecid=123, chip=0x8942, board=0x0a, profile='p105', + handoff_id='a'*32, template_sha256=hashlib.sha256(data).hexdigest()) + return cascadia.bind_host_identity(data, **{**values, **changes}) + + +class PublicRecovery(unittest.TestCase): + def test_platform_policy_uses_descriptor_identity_and_manual_storage(self): + selected = settings('p105') + self.assertEqual(selected['identity'], POLICY) + self.assertEqual(selected['runtime']['usb_network'], 'ecm') + self.assertEqual(selected['runtime']['manual_modules'], ['apple_sftl']) + self.assertEqual(selected['runtime']['storage_activation'], 'manual') + self.assertIsNone(settings('j71b')) + + def test_host_binding_keeps_kernel_initrd_and_template(self): + original = payload() + self.assertEqual(cascadia.host_identity_policy(original), POLICY) + bound = bind(original) + before, _, _ = cascadia._payload_parts(original) + after, _, args = cascadia._payload_parts(bound) + self.assertEqual(original[before['kernel_offset']:before['dtb_offset']], + bound[after['kernel_offset']:after['dtb_offset']]) + self.assertEqual(original[before['initrd_offset']:], bound[after['initrd_offset']:]) + fields = protocol.parameters(args) + self.assertEqual(fields[protocol.POLICY_PARAMETER], POLICY) + self.assertEqual(fields[protocol.HANDOFF_PARAMETER]['template_sha256'], hashlib.sha256(original).hexdigest()) + self.assertEqual(fields[protocol.HANDOFF_PARAMETER]['handoff_id'], 'a'*32) + with self.assertRaisesRegex(ValueError, 'already contains'): + cascadia.host_identity_policy(bound) + with self.assertRaisesRegex(ValueError, 'already contains'): + bind(bound) + + def test_selected_board_digest_and_complete_inputs_fail_before_binding(self): + original = payload() + for changes in ({'profile': 'j1'}, {'chip': 1}, {'board': 1}, {'ecid': True}, + {'handoff_id': '0'*32}, {'template_sha256': '0'*64}): + with self.subTest(changes=changes), self.assertRaises(ValueError): + bind(original, **changes) + with self.assertRaisesRegex(ValueError, 'First DTB compatible'): + payload(compatible=b'apple,s5l8942x\0apple,p105ap\0') + self.assertIsNone(cascadia.host_identity_policy(payload(policy=None))) + + def test_arm_command_line_full_bound_and_truncation(self): + example = payload('x') + _, _, args = cascadia._payload_parts(bind(example)) + padding = protocol.MAX_CMDLINE_BYTES - len(args) + self.assertGreater(padding, 0) + maximum = payload('x'*(padding+1)) + _, _, full = cascadia._payload_parts(bind(maximum)) + self.assertEqual(len(full), 1023) + with self.assertRaisesRegex(ValueError, 'command-line bound'): + bind(payload('x'*(padding+2))) + with self.assertRaises(ValueError): + protocol.parameters(full[:-1]) + + def test_corrupted_configuration_and_tree_are_refused(self): + data = payload() + bad = bytearray(data) + bad[cascadia.CONFIG_OFFSET+8] ^= 1 + with self.assertRaises(ValueError): + cascadia.host_identity_policy(bytes(bad)) + values = cascadia.configuration(data) + bad = bytearray(data) + bad[values['dtb_offset']] ^= 1 + with self.assertRaises(ValueError): + cascadia.host_identity_policy(bytes(bad)) + + +class RecoveryModulePolicy(unittest.TestCase): + def test_runtime_skips_only_recorded_builtin_modules(self): + import importlib.util + import sys + import tempfile + from pathlib import Path + from unittest import mock + path = Path(__file__).resolve().parents[1]/'runtime/recovery/policy.py' + specification = importlib.util.spec_from_file_location('recovery_policy_runtime', path) + runtime = importlib.util.module_from_spec(specification) + with mock.patch.dict(sys.modules, {'recovery_identity': protocol}): + specification.loader.exec_module(runtime) + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + (directory/'modules.builtin').write_text('kernel/libcomposite.ko\nkernel/usb-f-ecm.ko\n') + value = {'usb_modules': ['libcomposite', 'usb_f_ecm', 'usb_f_acm']} + self.assertEqual(runtime.loadable_modules(value, directory), ['usb_f_acm']) + (directory/'modules.builtin').unlink() + with self.assertRaises(FileNotFoundError): + runtime.loadable_modules(value, directory) + + def test_builtin_usb_is_accepted_and_builtin_manual_storage_is_refused(self): + import tempfile + from pathlib import Path + from orchard.image.prepare_runtime import check_recovery_modules + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + modules = root/'usr/lib/modules/test' + modules.mkdir(parents=True) + selected = settings('p105') + overlapping = {'runtime': {**selected['runtime'], 'manual_modules': ['usb-f-ecm']}} + with self.assertRaisesRegex(ValueError, 'manual module cannot be loaded'): + check_recovery_modules(root, 'test', overlapping) + builtin = modules/'modules.builtin' + builtin.write_text('kernel/libcomposite.ko\nkernel/usb_f_ecm.ko\nkernel/usb_f_acm.ko\n') + check_recovery_modules(root, 'test', selected) + builtin.write_text(builtin.read_text() + 'kernel/apple-sftl.ko\n') + with self.assertRaisesRegex(ValueError, 'Manual recovery module is built in'): + check_recovery_modules(root, 'test', selected) + builtin.write_text('') + with self.assertRaisesRegex(ValueError, 'USB module is unavailable'): + check_recovery_modules(root, 'test', selected) + for name in selected['runtime']['usb_modules']: + (modules/(name+'.ko')).write_bytes(b'fixture') + check_recovery_modules(root, 'test', selected) From 78ed09b1c4d17949ad073333b27832ba67f6db5e Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:03:06 -0600 Subject: [PATCH 05/16] boot: stop the Cascadia watchdog before validation Clear the profile watchdog before reading the checked handoff configuration. Rejected payloads now remain available for inspection instead of resetting while the loader waits. Cover the rejected-config path in the Linux runtime fixture. Assisted-by: LLM --- boot/cascadia/README.md | 6 ++++-- boot/cascadia/linux_boot.c | 10 +++++----- tests/fixtures/cascadia-runtime.c | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md index 2a5eccd6..3ebf964b 100644 --- a/boot/cascadia/README.md +++ b/boot/cascadia/README.md @@ -16,8 +16,10 @@ 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 loader validates its configuration checksum and bounds -before copying the initramfs to the profile's reserved high-memory range. +eight bytes. The loader disables the profile's watchdog before it validates +the configuration, so a rejected image remains stopped for inspection. It +checks the configuration checksum and bounds before copying the initramfs to +the profile's reserved high-memory range. It then performs the Cascadia cache/MMU handoff with the DTB in `r2`. The fixed loader stack uses the gap below offset `0x7000`; code must finish below `0x6000`, and the configuration starts at `0x7f00`. diff --git a/boot/cascadia/linux_boot.c b/boot/cascadia/linux_boot.c index 16526a73..45061372 100644 --- a/boot/cascadia/linux_boot.c +++ b/boot/cascadia/linux_boot.c @@ -51,6 +51,11 @@ u32 cascadia_main(void) volatile u8 *to; 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) || @@ -74,11 +79,6 @@ u32 cascadia_main(void) dtb = (volatile const u8 *)(LOAD_ADDRESS + c->dtb_offset); if (big_endian(dtb) != 0xd00dfeedu || big_endian(dtb + 4) != c->dtb_size) return 0; - if (WATCHDOG_ADDRESS) { - volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; - for (i = 0; i < 4; i++) - wdt[i] = 0; - } from = (volatile const u8 *)(LOAD_ADDRESS + c->initrd_offset); to = (volatile u8 *)INITRD_ADDRESS; for (i = 0; i < c->initrd_size; i++) diff --git a/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c index b3f276e9..3577beac 100644 --- a/tests/fixtures/cascadia-runtime.c +++ b/tests/fixtures/cascadia-runtime.c @@ -56,7 +56,7 @@ static void rejects(void) 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] == 0xa5); + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); } int main(void) From 3efd9e12383f0250118195e6e14c9beb982fa53d Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:13:53 -0600 Subject: [PATCH 06/16] boot: relocate the Cascadia initrd after MMU teardown Validate the uploaded image before touching reserved high RAM. Capture its checked relocation fields, flush the inherited caches, and disable the MMU before copying the initrd through physical addresses. This removes the dependency on an inherited iBEC mapping for the destination. Clear the watchdog in assembly before stack setup. Exercise protected high RAM during validation and check word and tail copies through the complete P105 initrd size. Assisted-by: LLM --- boot/cascadia/README.md | 14 ++++++++----- boot/cascadia/linux_boot.c | 26 +++++++++++++++++------ boot/cascadia/linux_boot_start.S | 23 +++++++++++++++++++++ tests/fixtures/cascadia-runtime.c | 34 +++++++++++++++++++++++++++++-- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md index 3ebf964b..e94076f0 100644 --- a/boot/cascadia/README.md +++ b/boot/cascadia/README.md @@ -16,11 +16,15 @@ 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 loader disables the profile's watchdog before it validates -the configuration, so a rejected image remains stopped for inspection. It -checks the configuration checksum and bounds before copying the initramfs to -the profile's reserved high-memory range. -It then performs the Cascadia cache/MMU handoff with the DTB in `r2`. +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`. diff --git a/boot/cascadia/linux_boot.c b/boot/cascadia/linux_boot.c index 45061372..952fc39e 100644 --- a/boot/cascadia/linux_boot.c +++ b/boot/cascadia/linux_boot.c @@ -47,8 +47,7 @@ static u32 big_endian(volatile const u8 *p) u32 cascadia_main(void) { volatile const struct config *c = &cascadia_config; - volatile const u8 *dtb, *from; - volatile u8 *to; + volatile const u8 *dtb; u32 i; if (WATCHDOG_ADDRESS) { @@ -70,6 +69,7 @@ u32 cascadia_main(void) 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 || @@ -79,9 +79,23 @@ u32 cascadia_main(void) dtb = (volatile const u8 *)(LOAD_ADDRESS + c->dtb_offset); if (big_endian(dtb) != 0xd00dfeedu || big_endian(dtb + 4) != c->dtb_size) return 0; - from = (volatile const u8 *)(LOAD_ADDRESS + c->initrd_offset); - to = (volatile u8 *)INITRD_ADDRESS; - for (i = 0; i < c->initrd_size; i++) - to[i] = from[i]; 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 index 1052e304..b2c10160 100644 --- a/boot/cascadia/linux_boot_start.S +++ b/boot/cascadia/linux_boot_start.S @@ -16,6 +16,15 @@ _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. */ @@ -32,6 +41,13 @@ _start: */ 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 @@ -51,6 +67,13 @@ linux_boot_jump: 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 diff --git a/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c index 3577beac..791d542f 100644 --- a/tests/fixtures/cascadia-runtime.c +++ b/tests/fixtures/cascadia-runtime.c @@ -62,17 +62,42 @@ static void rejects(void) int main(void) { CHECK(sizeof(u32) == 4 && sizeof(struct config) == 68); - memory(LOAD_ADDRESS, 0x20000); - memory(INITRD_ADDRESS, 0x10000); + 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++) { @@ -91,6 +116,11 @@ int main(void) 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(); From e7a85d4fea651bb45ba98e5ccc1f3ae061d757b5 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:03:54 -0600 Subject: [PATCH 07/16] p105: Describe A5 host and on-device boot inputs Record the exact prepared iBEC and the data needed by the Recovery autogo and iOS local-file methods. Keep current hardware feature evidence separate from the new startup candidates. Assisted-by: LLM --- chips/s5l8942x.toml | 1 + devices/p105/boot.json | 37 ++++++++++++++++++++++++++++++++++++- devices/p105/device.toml | 5 +++-- docs/compatibility.md | 2 +- methods.toml | 8 ++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/chips/s5l8942x.toml b/chips/s5l8942x.toml index 32a4fc52..b6910463 100644 --- a/chips/s5l8942x.toml +++ b/chips/s5l8942x.toml @@ -15,6 +15,7 @@ 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/boot.json b/devices/p105/boot.json index b2322755..eb0f9ff3 100644 --- a/devices/p105/boot.json +++ b/devices/p105/boot.json @@ -49,7 +49,42 @@ "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": { + "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/device.toml b/devices/p105/device.toml index 95a55378..b5455b1c 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -15,9 +15,10 @@ board = 0x0a [boot] # 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"] +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/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/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" From 2748a42b40429f7d89bd1d96fe3ca34d5e3bcc27 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:04:39 -0600 Subject: [PATCH 08/16] apple: Boot ARM32 payloads through a checked iBEC Prepare descriptor-pinned plaintext IMG3 inputs privately and route Recovery through the generic adapter. Upload one complete CB32 payload from an attempt-private snapshot, record uncertain outcomes before mutation, and bind readiness to the selected device and boot. Assisted-by: LLM --- docs/adapter-contract.md | 2 +- src/orchard/apple/cascadia.py | 259 +++++++++++++++++++++++++++ src/orchard/apple/device.py | 20 ++- src/orchard/apple/iboot32.py | 169 ++++++++++++++++++ src/orchard/apple/linux_usb.py | 4 +- src/orchard/apple/usb.py | 9 +- src/orchard/boot.py | 3 + src/orchard/command.py | 20 ++- src/orchard/contracts.py | 12 +- src/orchard/devices.py | 4 +- src/orchard/hardware.py | 32 +++- tests/test_catalog.py | 3 +- tests/test_recovery_autogo.py | 317 +++++++++++++++++++++++++++++++++ tools/catalog/generate | 2 +- 14 files changed, 834 insertions(+), 22 deletions(-) create mode 100644 src/orchard/apple/cascadia.py create mode 100644 src/orchard/apple/iboot32.py create mode 100644 tests/test_recovery_autogo.py diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index bfc3288f..6eb2a554 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -21,7 +21,7 @@ Arguments and return types are defined in `contracts.py`, not repeated in a seco - `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. +- `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. diff --git a/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py new file mode 100644 index 00000000..ef9a66a7 --- /dev/null +++ b/src/orchard/apple/cascadia.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""One-shot 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.state import DeviceState, private_directory +from . import iboot32, usb + +CONFIG_AT = 0x7f00 +CONFIG = struct.Struct('<4s16I') + + +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 single-upload CB32 payload 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') + 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(' 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'): + 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: + raise RuntimeError('Recovery upload outcome is unknown; inspect the device before another attempt') + + +def boot(device, assets, manifest, progress, *, profile, chip, board, product): + if device['mode'] == 'linux': + current = usb.recovery(product, profile) + if current['ecid'] != device['ecid'] or current['boot_id'] != device.get('boot_id'): + raise ValueError('Selected running recovery changed') + return + if device['mode'] not in ('dfu', 'recovery') or (device.get('chip'), device.get('board')) != (chip, board): + raise ValueError('Enter the selected board in kDFU or iBEC Recovery first') + prepared_root = assets.get('prepared_boot') or os.environ.get('ORCHARD_PREPARED_BOOT') + stage, recipe, source_receipt = iboot32.verified(profile, prepared_root) + executable = tool() + recovery = Path(assets['recovery']) + expected = manifest['assets']['recovery']['sha256'] + payload = bundle(recovery, expected, recipe) + observed, queried = selected(device, executable, mode=device['mode'], product=recipe['device']) + if device['mode'] == 'dfu' and not observed.get('pwned'): + raise ValueError('Plain DFU is unsupported here; enter a pwned iBSS or iBEC Recovery first') + folder = private_directory(DeviceState(profile, device['ecid']).root / 'boot-attempts') + identifier = secrets.token_hex(16) + attempt = folder / (identifier + '.json') + image_copy = folder / (identifier + '-recovery.bin') + stage_copy = folder / (identifier + '-ibec.img3') + record = {'schema': 1, 'method': 'recovery-autogo', 'ecid': device['ecid'], 'profile': profile, + 'build': recipe['build'], 'recovery_sha256': expected, + 'payload_format': 'cascadia-cb32', + 'ibec_sha256': source_receipt['ibec']['sha256'], 'phase': 'validated'} + try: + # Freeze inputs so another checkout rebuilding a shared component cannot + # replace an upload after this operation has validated it. + for data, path, checksum in ((payload, image_copy, expected), + (stage.read_bytes(), stage_copy, record['ibec_sha256'])): + if hashlib.sha256(data).hexdigest() != checksum: + raise ValueError('Boot input changed while creating its private snapshot') + with path.open('xb') as stream: + stream.write(data) + path.chmod(0o600) + save(attempt, record) + if device['mode'] == 'dfu': + record['phase'] = 'ibec-upload-outcome-unknown' + save(attempt, record) + upload(device, stage_copy, executable, product=recipe['device'], mode='dfu', + expected=record['ibec_sha256']) + wait_recovery(device, executable, product=recipe['device']) + record['phase'] = 'recovery-upload-outcome-unknown' + save(attempt, record) + progress.phase('Uploading ARM32 recovery') + upload(device, image_copy, executable, product=recipe['device'], expected=expected) + record['phase'] = 'awaiting-linux' + save(attempt, record) + deadline = time.monotonic() + 120 + while True: + try: + observations = [row for row in usb.devices() + if row['mode'] in ('dfu', 'recovery', 'pongo', 'linux')] + if len(observations) > 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') + 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, 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..9ae7339d 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) @@ -87,6 +91,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 +111,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(' 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/hardware.py b/src/orchard/hardware.py index 9eea2e88..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(): @@ -90,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. @@ -112,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 @@ -121,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/tests/test_catalog.py b/tests/test_catalog.py index 62ac0c52..ae30eda4 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -240,7 +240,8 @@ def test_recovery_follows_from_the_kernel(self): self.assertEqual(devices.load('n61').PRODUCT, 'Orchard Recovery') self.assertEqual(devices.load('j96').PRODUCT, 'Orchard Recovery') self.assertIsNone(devices.load('k93').PRODUCT) # no kernel - self.assertIsNone(devices.load('p105').PRODUCT) # 32-bit: no recovery root yet + self.assertIsNone(devices.load('j1').PRODUCT) # no implemented handoff/profile + self.assertEqual(devices.load('p105').PRODUCT, 'Orchard Recovery') # ARMv7 has its own recovery producer def test_the_boards_with_every_requirement_can_install(self): from orchard import hardware diff --git a/tests/test_recovery_autogo.py b/tests/test_recovery_autogo.py new file mode 100644 index 00000000..80fc36fd --- /dev/null +++ b/tests/test_recovery_autogo.py @@ -0,0 +1,317 @@ +# SPDX-License-Identifier: GPL-2.0-only +import copy +import hashlib +import json +from pathlib import Path +import struct +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import Mock, patch +import zlib + +from orchard.apple import cascadia, iboot32 + + +def img3(payload, encrypted=True): + tag = iboot32.TAG + data = tag.pack(b'ATAD', 12 + len(payload), len(payload)) + payload + if encrypted: + data += tag.pack(b'GABK', 44, 32) + bytes(32) + return iboot32.HEADER.pack(b'3gmI', len(data) + 20, len(data), 0, b'cebi') + data + + +def fixture(): + base, hook, target = 0x9ff00000, 0x50, 0x100 + raw = bytearray(0x300) + struct.pack_into(' Date: Fri, 6 Nov 2026 12:05:28 -0600 Subject: [PATCH 09/16] apple: Build the on-device iOS launcher from source Prepare a private file inventory with exact model, iOS-build and asset checks. Keep the owned wake alive during one source-built handoff and cancel it on returning paths. Preserve the upstream kloader source and apply a separately pinned patch that bounds reads, searches and the sleep wait. [Orchard: from axi0mX/ios-kexec-utils a1bbca8a8f2847d9c53070e76e0b5d093bda5993; preserve winocm attribution and GPL source, add host build adaptation and bounded handoff.] Assisted-by: LLM --- NOTICE | 7 + boot/ios-launcher/README.md | 106 ++ boot/ios-launcher/build | 6 + boot/ios-launcher/ibec-local.c | 42 + boot/ios-launcher/ios-launcher.m | 96 ++ boot/ios-launcher/ios-start.c | 247 +++++ boot/ios-launcher/kloader/.gitattributes | 3 + boot/ios-launcher/kloader/README.md | 53 + boot/ios-launcher/kloader/build.py | 225 ++++ boot/ios-launcher/kloader/hardening.patch | 461 ++++++++ boot/ios-launcher/kloader/kloader.c | 1194 +++++++++++++++++++++ boot/ios-launcher/kloader/source.json | 11 + boot/ios-launcher/kloader/tfp0.plist | 12 + boot/ios-launcher/shim-build | 10 + src/orchard/apple/ios_kloader.py | 11 + src/orchard/apple/ios_launcher.py | 459 ++++++++ src/orchard/command.py | 6 + tests/fixtures/ios-kloader-io.c | 189 ++++ tests/fixtures/ios-launcher-wake.c | 176 +++ tests/test_ios_kloader.py | 134 +++ tests/test_ios_launcher.py | 164 +++ tests/test_ios_launcher_wake.py | 42 + 22 files changed, 3654 insertions(+) create mode 100644 boot/ios-launcher/README.md create mode 100755 boot/ios-launcher/build create mode 100644 boot/ios-launcher/ibec-local.c create mode 100644 boot/ios-launcher/ios-launcher.m create mode 100644 boot/ios-launcher/ios-start.c create mode 100644 boot/ios-launcher/kloader/.gitattributes create mode 100644 boot/ios-launcher/kloader/README.md create mode 100644 boot/ios-launcher/kloader/build.py create mode 100644 boot/ios-launcher/kloader/hardening.patch create mode 100644 boot/ios-launcher/kloader/kloader.c create mode 100644 boot/ios-launcher/kloader/source.json create mode 100644 boot/ios-launcher/kloader/tfp0.plist create mode 100755 boot/ios-launcher/shim-build create mode 100644 src/orchard/apple/ios_kloader.py create mode 100644 src/orchard/apple/ios_launcher.py create mode 100644 tests/fixtures/ios-kloader-io.c create mode 100644 tests/fixtures/ios-launcher-wake.c create mode 100644 tests/test_ios_kloader.py create mode 100644 tests/test_ios_launcher.py create mode 100644 tests/test_ios_launcher_wake.py 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/ios-launcher/README.md b/boot/ios-launcher/README.md new file mode 100644 index 00000000..cd732544 --- /dev/null +++ b/boot/ios-launcher/README.md @@ -0,0 +1,106 @@ +# 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. + +## 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..03e38044 --- /dev/null +++ b/boot/ios-launcher/ibec-local.c @@ -0,0 +1,42 @@ +/* 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; + +__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))); + } + if (~crc != LOCAL_BOOT_CRC32) + 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-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-start.c b/boot/ios-launcher/ios-start.c new file mode 100644 index 00000000..841a190e --- /dev/null +++ b/boot/ios-launcher/ios-start.c @@ -0,0 +1,247 @@ +/* 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 "ios-start-config.h" + +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; +} + +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 fd = open(LEASE_PATH, O_RDWR | O_CREAT | O_NOFOLLOW, 0600); + 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)) + goto refused; + if (pread(fd, previous, (size_t)info.st_size, 0) != info.st_size) + goto refused; + if (info.st_size && (strncmp(previous, "v1:", 3) || !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, + IBEC_PATH, NULL}; + 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; + 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; + 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/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..aaad496f --- /dev/null +++ b/src/orchard/apple/ios_launcher.py @@ -0,0 +1,459 @@ +# 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 +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'] + validate_profile(profile) + return profile + + +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) + + +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): + 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' + return result + + +def build_shim(ibec, image, output, profile, *, clang='clang-19', linker='ld.lld-19', objcopy='llvm-objcopy-19', host_toolchain=False): + 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_boot_path']) + '\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} + (output / 'result.json').write_text(json.dumps(report, indent=2) + '\n') + return report + + +def build_launcher(boot_image, ibec, kloader, output, profile, title='Orchard'): + args = SimpleNamespace(boot_image=Path(boot_image), ibec=Path(ibec), + kloader=Path(kloader), output=Path(output)) + source = Path(hardware.ROOT) / 'boot/ios-launcher' + if args.output.is_symlink(): + raise ValueError('Linked output directory refused') + out = args.output.resolve() + out.mkdir(mode=0o700,parents=True,exist_ok=False) + assets = {'ROOT_BOOT':args.boot_image,'IBEC':args.ibec,'KLOADER':args.kloader} + header = launcher_config(profile, title) + inputs = {} + for name,path in assets.items(): + raw = path.read_bytes() + if not 0 < len(raw) < 64*(1<<20): + raise ValueError('Unexpected launcher input size') + sha = hashlib.sha256(raw).hexdigest() + inputs[name] = {'sha256':sha,'bytes':len(raw)} + header += f'#define {name}_BYTES {len(raw)}u\n#define {name}_SHA256 "{sha}"\n' + (out/'ios-start-config.h').write_text(header) + base = ['xcrun','--sdk','iphoneos','clang','-arch','armv7', + '-miphoneos-version-min='+profile['minimum_ios'],'-Os','-Wall','-Wextra','-Werror'] + run(*base,'-std=c11','-marm','-fno-builtin','-D_FORTIFY_SOURCE=0','-I',str(out), + '-c',str(source/'ios-start.c'),'-o',str(out/'start.o')) + # Current Apple linkers omit the Thumb bit in ARMv7 LC_MAIN. Compile + # the app in ARM mode as well, then verify both finished entry points. + run(*base,'-marm','-fobjc-arc','-fblocks','-I',str(out),'-c',str(source/'ios-launcher.m'),'-o',str(out/'launcher.o')) + libsystem = library(out,'libSystem','/usr/lib/libSystem.B.dylib',[ + '_'+name for name in ('CC_SHA256','__stack_chk_fail','__stack_chk_guard','close','execve', + 'fstat','fsync','geteuid','mach_port_deallocate','mach_task_self_','mmap','munmap','open', + 'setgid','setuid','snprintf','strcmp','sysctlbyname','task_for_pid','write', + '_NSConcreteStackBlock','_dispatch_main_q','dispatch_async','dispatch_get_global_queue', + 'posix_spawn','waitpid','puts','strncmp','flock','ftruncate','pread', + 'dlopen','dlsym','dlclose','getpid','__error','memset','floor')]) + objc = library(out,'libobjc','/usr/lib/libobjc.A.dylib',[ + '_'+name for name in ('_objc_empty_cache','objc_alloc','objc_autoreleasePoolPop', + 'objc_autoreleasePoolPush','objc_msgSend','objc_msgSend_stret','objc_release', + 'objc_retain','objc_retainAutoreleasedReturnValue','objc_storeStrong')]) + ui = library(out,'UIKit','/System/Library/Frameworks/UIKit.framework/UIKit', + ['_UIApplicationMain'],['UIButton','UIColor','UIFont','UILabel','UIResponder', + 'UIScreen','UIViewController','UIWindow']) + foundation = library(out,'Foundation','/System/Library/Frameworks/Foundation.framework/Foundation', + ['_NSStringFromClass','_NSClassFromString'],['NSArray','NSObject']) + core = library(out,'CoreFoundation','/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation', + ['___CFConstantStringClassReference','_CFRelease','_CFGetTypeID','_CFArrayGetTypeID', + '_CFArrayGetCount','_CFArrayGetValueAtIndex','_CFDictionaryGetTypeID', + '_CFDictionaryGetValue','_CFEqual','_CFAbsoluteTimeGetCurrent','_CFDateCreate', + '_CFStringCreateWithCString']) + graphics = library(out,'CoreGraphics','/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics', + ['_CGRectZero']) + run(*base,'-nostdlib',str(out/'start.o'),core,libsystem,'-Wl,-bind_at_load','-Wl,-no_uuid', + '-o',str(out/'orchard-ios-start')) + app = out/'Orchard.app' + app.mkdir() + run(*base,'-nostdlib',str(out/'launcher.o'),ui,foundation,core,graphics,objc,libsystem, + '-Wl,-bind_at_load','-Wl,-no_uuid','-o',str(app/'Orchard')) + info = {'CFBundleIdentifier':profile['bundle_id'],'CFBundleName':title, + 'CFBundleDisplayName':title,'CFBundleExecutable':'Orchard', + 'CFBundlePackageType':'APPL','CFBundleVersion':'1','CFBundleShortVersionString':'0.1', + 'MinimumOSVersion':profile['minimum_ios'],'LSRequiresIPhoneOS':True,'UIDeviceFamily':[2], + 'UISupportedInterfaceOrientations':['UIInterfaceOrientationPortrait']} + (app/'Info.plist').write_bytes(plistlib.dumps(info)) + entitlements = out/'entitlements.plist' + entitlements.write_bytes(plistlib.dumps({'platform-application':True, + 'com.apple.private.security.no-container':True,'task_for_pid-allow':True})) + for target in (out/'orchard-ios-start',app): + run('/usr/bin/codesign','--force','--sign','-','--timestamp=none', + '--digest-algorithm=sha1,sha256','--entitlements',str(entitlements),str(target)) + run('/usr/bin/codesign','--verify','--strict',str(target)) + outputs = {} + for path in (out/'orchard-ios-start',app/'Orchard'): + path.chmod(0o755) + raw = path.read_bytes() + outputs[str(path.relative_to(out))] = {'sha256':hashlib.sha256(raw).hexdigest(), + 'bytes':len(raw),'entry':check_entry(path)} + report = {'schema':1,'device_operations':0,'architecture':'armv7','minimum_ios':profile['minimum_ios'], + 'inputs':inputs,'outputs':outputs,'signing':'ad-hoc SHA-1 and SHA-256; requires owner jailbreak', + 'source_files':{**{name:hashlib.sha256((source/name).read_bytes()).hexdigest() + for name in ('ios-start.c','ios-launcher.m')}, + 'producer': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}, + 'on_device_startup_qualified':False} + (out/'result.json').write_text(json.dumps(report,indent=2)+'\n') + return report + + + +def checked_kloader(path, receipt): + """Accept the pinned source build, with a receipt bound to the actual bytes.""" + from orchard.apple import ios_kloader + path, receipt = Path(path), Path(receipt) + if path.is_symlink() or receipt.is_symlink() or not path.is_file() or not receipt.is_file(): + raise ValueError('Kloader and its source-build receipt must be regular files') + raw = path.read_bytes() + record = json.loads(receipt.read_text()) + if (record.get('schema') != 1 or record.get('kind') != 'ios-kloader-build' + or not 0 < len(raw) < 64 * (1 << 20) + or record.get('hardening_patch_sha256') != ios_kloader.HARDENING_PATCH_SHA256 + or record.get('derived_source_sha256') != ios_kloader.DERIVED_SOURCE_SHA256 + or record.get('sleep_wait_seconds') != ios_kloader.SLEEP_WAIT_SECONDS + or record.get('source_commit') != ios_kloader.SOURCE_COMMIT + or record.get('source_sha256') != ios_kloader.SOURCE_SHA256 + or record.get('artifact_sha256') != hashlib.sha256(raw).hexdigest() + or record.get('artifact_bytes') != len(raw) or record.get('signed') is not True): + raise ValueError('Kloader differs from the pinned signed source-build receipt') + check_entry(path) + return record + + +def prepare(device, ibec, boot_image, kloader, kloader_receipt, output, *, title='Orchard', + clang='clang-19', linker='ld.lld-19', objcopy='llvm-objcopy-19', host_toolchain=False): + """Build ordinary host files and a private installation manifest, without device I/O.""" + profile = boot_profile(device) + kloader_result = checked_kloader(kloader, kloader_receipt) + from orchard.apple import ios_kloader + if profile['wake_delay_seconds'] > 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') + output.mkdir(mode=0o700, parents=True, exist_ok=False) + shim = output / 'shim' + shim_result = build_shim(ibec, boot_image, shim, profile, clang=clang, linker=linker, objcopy=objcopy, host_toolchain=host_toolchain) + native = output / 'native' + native_result = build_launcher(boot_image, shim / 'ibec-local.raw', kloader, native, profile, title) + 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(boot_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} + (output / 'installation.json').write_text(json.dumps(report, indent=2) + '\n') + return report + + +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/command.py b/src/orchard/command.py index dbf4013d..1e29f025 100644 --- a/src/orchard/command.py +++ b/src/orchard/command.py @@ -103,6 +103,9 @@ 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) @@ -120,6 +123,9 @@ 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) 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/test_ios_kloader.py b/tests/test_ios_kloader.py new file mode 100644 index 00000000..03852ec9 --- /dev/null +++ b/tests/test_ios_kloader.py @@ -0,0 +1,134 @@ +import hashlib +import importlib.util +import json +from pathlib import Path +import platform +import shutil +import struct +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "boot/ios-launcher/kloader" +SPEC = importlib.util.spec_from_file_location("ios_kloader_build", SOURCE / "build.py") +builder = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(builder) + + +class KloaderSourceTests(unittest.TestCase): + def copy_sources(self, directory): + for name in ("source.json", "kloader.c", "tfp0.plist"): + shutil.copyfile(SOURCE / name, directory / name) + + def test_source_identity_matches_launcher_contract(self): + pin = builder.inputs() + module_spec = importlib.util.spec_from_file_location( + "ios_kloader_identity", ROOT / "src/orchard/apple/ios_kloader.py") + identity = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(identity) + self.assertEqual(pin["commit"], identity.SOURCE_COMMIT) + self.assertEqual(pin["files"]["kloader.c"], identity.SOURCE_SHA256) + self.assertEqual(builder.HARDENING_PATCH_SHA256, identity.HARDENING_PATCH_SHA256) + self.assertEqual(builder.DERIVED_SOURCE_SHA256, identity.DERIVED_SOURCE_SHA256) + self.assertEqual(builder.SLEEP_WAIT_SECONDS, identity.SLEEP_WAIT_SECONDS) + self.assertIn("Copyright 2014, winocm.", (SOURCE / "kloader.c").read_text()) + + def test_changed_source_is_rejected_before_build(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.copy_sources(directory) + path = directory / "kloader.c" + path.write_bytes(path.read_bytes() + b"\n/* altered */\n") + with self.assertRaisesRegex(ValueError, "Pinned kloader source differs"): + builder.inputs(directory) + + def test_repinning_altered_source_requires_contract_update(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.copy_sources(directory) + path = directory / "kloader.c" + path.write_bytes(path.read_bytes() + b"\n/* altered */\n") + manifest = directory / "source.json" + pin = json.loads(manifest.read_text()) + pin["files"]["kloader.c"] = hashlib.sha256(path.read_bytes()).hexdigest() + manifest.write_text(json.dumps(pin)) + with self.assertRaisesRegex(ValueError, "Unsupported kloader source description"): + builder.inputs(directory) + + def test_wrong_architecture_is_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "foreign" + path.write_bytes(struct.pack("<7I", 0xFEEDFACE, 0x100000C, 0, 2, 0, 0, 0)) + with self.assertRaisesRegex(ValueError, "thin ARMv7"): + builder.image(path) + + def test_truncated_commands_are_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "incomplete" + path.write_bytes(struct.pack("<7I", 0xFEEDFACE, 12, 9, 2, 1, 56, 0)) + with self.assertRaisesRegex(ValueError, "load-command table"): + builder.image(path) + + def test_patch_is_exact_and_rejects_changes(self): + self.assertEqual(hashlib.sha256(builder.patched_source().encode()).hexdigest(), + builder.DERIVED_SOURCE_SHA256) + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + self.copy_sources(directory) + (directory / "hardening.patch").write_bytes((SOURCE / "hardening.patch").read_bytes() + b"\n") + with self.assertRaisesRegex(ValueError, "hardening patch differs"): + builder.patched_source(directory) + + def test_dangling_symlink_output_is_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + link = directory / "link" + link.symlink_to(directory / "absent") + with self.assertRaisesRegex(ValueError, "must not be a symlink"): + builder.build(link) + self.assertFalse((directory / "absent").exists()) + + @unittest.skipUnless(shutil.which("cc"), "requires a C compiler") + def test_actual_patched_io_paths(self): + source = builder.patched_source() + start = source.index("/* Orchard: bounded I/O") + end = source.index("\nstatic void generate_ttb_entries", start) + helpers = source[start:end] + fixture = (ROOT / "tests/fixtures/ios-kloader-io.c").read_text() + self.assertEqual(fixture.count("/* ACTUAL_PATCHED_HELPERS */"), 1) + with tempfile.TemporaryDirectory(prefix="orchard-kloader-io-") as temporary: + directory = Path(temporary) + (directory / "fixture.c").write_text(fixture.replace("/* ACTUAL_PATCHED_HELPERS */", helpers)) + command = ["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", + str(directory / "fixture.c"), "-o", str(directory / "fixture")] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(directory / "fixture")], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + self.assertIn("region exhaustion and one sleep request passed", ran.stdout) + + +@unittest.skipUnless(platform.system() == "Darwin" and shutil.which("xcrun"), + "requires the Apple iPhoneOS SDK and codesign") +class KloaderBuildTests(unittest.TestCase): + def test_signed_source_build_is_reproducible(self): + with tempfile.TemporaryDirectory(prefix="orchard-kloader-test-") as temporary: + first = builder.build(Path(temporary) / "first") + second = builder.build(Path(temporary) / "second") + self.assertEqual(first["artifact_sha256"], second["artifact_sha256"]) + self.assertEqual(first["source_sha256"], builder.SOURCE_SHA256) + self.assertEqual(first["output"]["architecture"], "armv7") + self.assertTrue(first["signed"]) + self.assertFalse(first["on_device_qualified"]) + self.assertEqual(first["device_operations"], 0) + path = Path(temporary) / "first/kloader" + self.assertEqual(first["artifact_sha256"], hashlib.sha256(path.read_bytes()).hexdigest()) + with self.assertRaises(FileExistsError): + builder.build(Path(temporary) / "first") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ios_launcher.py b/tests/test_ios_launcher.py new file mode 100644 index 00000000..5bea4d28 --- /dev/null +++ b/tests/test_ios_launcher.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Host-only checks for the pinned iOS launcher producer.""" +import copy +import hashlib +import json +from pathlib import Path +import shutil +import struct +import subprocess +import sys +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from orchard import hardware +from orchard.apple import ios_launcher, ios_kloader + + +class RecipeTests(unittest.TestCase): + def setUp(self): + self.profile = ios_launcher.boot_profile('p105', root=hardware.ROOT) + + def test_profile_has_exact_prepared_firmware_and_thumb_entries(self): + self.assertEqual(self.profile['machine'], 'iPad2,5') + self.assertEqual(self.profile['prepared_ibec_bytes'], 282624) + self.assertEqual(self.profile['prepared_ibec_sha256'], + 'e987b101800bedf03c086aa55854ff594fcce1b0f55ea1424f3ccf180a40abce') + self.assertEqual(self.profile['ibec_boot_path'], '/boot' + self.profile['boot_path']) + self.assertTrue(all(self.profile[k] & 1 for k in ('finish_init', 'mount_bootfs', 'load_file'))) + + def test_other_board_is_refused(self): + with self.assertRaisesRegex(ValueError, 'iOS local-file'): + ios_launcher.boot_profile('j71b', root=hardware.ROOT) + + def test_address_and_string_bounds(self): + for key, value in (('shim_end', self.profile['prepared_ibec_bytes'] + 1), + ('finish_init', self.profile['finish_init'] & ~1), + ('load_address', self.profile['base']), ('wake_delay_seconds', 0), + ('boot_path', '/boot/../private/file'), ('ibec_path', 'relative'), + ('machine', 'iPad2,5\n'), ('prepared_ibec_bytes', True)): + with self.subTest(key=key): + p = dict(self.profile, **{key: value}) + with self.assertRaises(ValueError): + ios_launcher.validate_profile(p) + + def test_native_configuration_uses_selected_data(self): + p = dict(self.profile, wake_delay_seconds=90, machine='Synthetic1,1') + header = ios_launcher.launcher_config(p, 'A "test"') + self.assertIn('#define TARGET_MACHINE "Synthetic1,1"', header) + self.assertIn('#define WAKE_DELAY_SECONDS 90', header) + self.assertIn('#define LAUNCHER_TITLE @"A \\"test\\""', header) + self.assertIn('90 seconds.', header) + + def test_wrong_firmware_is_refused_before_output_or_compiler(self): + with TemporaryDirectory() as d: + d = Path(d) + (d / 'ibec').write_bytes(b'wrong') + (d / 'bundle').write_bytes(bytes.fromhex('c0000cf1') + bytes(40000)) + with patch.object(ios_launcher.subprocess, 'run') as run: + with self.assertRaisesRegex(ValueError, 'exact supported image'): + ios_launcher.build_shim(d / 'ibec', d / 'bundle', d / 'out', self.profile) + run.assert_not_called() + self.assertFalse((d / 'out').exists()) + + def test_patch_changes_only_hook_and_stub(self): + p = copy.deepcopy(self.profile) + original = bytearray(p['prepared_ibec_bytes']) + hook = p['hook'] + original[hook:hook + 4] = ios_launcher.thumb_bl(p['base'] + hook, p['finish_init'] & ~1) + p['prepared_ibec_sha256'] = hashlib.sha256(original).hexdigest() + stub = bytes.fromhex('00bf7047') + result = ios_launcher.patch_ibec(original, stub, p) + expected = bytearray(original) + expected[p['shim_start']:p['shim_start'] + len(stub)] = stub + expected[hook:hook + 4] = ios_launcher.thumb_bl(p['base'] + hook, p['base'] + p['shim_start']) + self.assertEqual(result, bytes(expected)) + for changed in (dict(p, prepared_ibec_sha256='0' * 64), dict(p, hook=hook + 2)): + with self.assertRaises(ValueError): + ios_launcher.patch_ibec(original, stub, changed) + original[p['shim_start']] = 1 + p['prepared_ibec_sha256'] = hashlib.sha256(original).hexdigest() + with self.assertRaisesRegex(ValueError, 'padding'): + ios_launcher.patch_ibec(original, stub, p) + + def test_thumb_branch_range(self): + self.assertEqual(len(ios_launcher.thumb_bl(0x1000, 0x2000)), 4) + for target in (0x2001, 0x1000 + (1 << 25)): + with self.assertRaises(ValueError): + ios_launcher.thumb_bl(0x1000, target) + + +class KloaderReceiptTests(unittest.TestCase): + def test_signed_bounded_source_receipt_is_bound_to_artifact(self): + with TemporaryDirectory() as directory: + d = Path(directory) + executable, receipt = d / 'kloader', d / 'result.json' + executable.write_bytes(b'synthetic signed artifact') + record = {'schema': 1, 'kind': 'ios-kloader-build', + 'source_commit': ios_kloader.SOURCE_COMMIT, + 'source_sha256': ios_kloader.SOURCE_SHA256, + 'hardening_patch_sha256': ios_kloader.HARDENING_PATCH_SHA256, + 'derived_source_sha256': ios_kloader.DERIVED_SOURCE_SHA256, + 'sleep_wait_seconds': ios_kloader.SLEEP_WAIT_SECONDS, + 'artifact_sha256': hashlib.sha256(executable.read_bytes()).hexdigest(), + 'artifact_bytes': executable.stat().st_size, 'signed': True} + receipt.write_text(json.dumps(record)) + with patch.object(ios_launcher, 'check_entry') as entry: + self.assertEqual(ios_launcher.checked_kloader(executable, receipt), record) + entry.assert_called_once_with(executable) + for key, value in (('hardening_patch_sha256', '0' * 64), + ('derived_source_sha256', '0' * 64), ('sleep_wait_seconds', 1), + ('artifact_sha256', '0' * 64), ('signed', False)): + with self.subTest(key=key): + receipt.write_text(json.dumps(dict(record, **{key: value}))) + with self.assertRaisesRegex(ValueError, 'source-build receipt'): + ios_launcher.checked_kloader(executable, receipt) + receipt.write_text(json.dumps(record)) + executable.write_bytes(b'changed artifact') + with self.assertRaises(ValueError): + ios_launcher.checked_kloader(executable, receipt) + + +@unittest.skipUnless(sys.platform == 'darwin' and shutil.which('xcrun'), + 'Native launcher checks need Apple clang and codesign') +class NativeBuildTests(unittest.TestCase): + def test_arm_entry_and_framework_stubs_build(self): + p = ios_launcher.boot_profile('p105', root=hardware.ROOT) + with TemporaryDirectory() as directory: + d = Path(directory) + for name in ('bundle', 'ibec', 'kloader'): + (d / name).write_bytes(b'synthetic build-only input') + result = ios_launcher.build_launcher(d / 'bundle', d / 'ibec', d / 'kloader', d / 'out', p) + self.assertEqual(result['device_operations'], 0) + self.assertFalse(result['on_device_startup_qualified']) + repeated = ios_launcher.build_launcher(d / 'bundle', d / 'ibec', d / 'kloader', d / 'repeat', p) + self.assertEqual(result['outputs'], repeated['outputs']) + for entry in result['outputs'].values(): + self.assertEqual(entry['entry']['mode'], 'arm') + executable = d / 'out/Orchard.app/Orchard' + bad = d / 'bad' + # Reproduce the historical LC_MAIN instruction-mode mismatch. + wrong_entry = bytearray(executable.read_bytes()) + cursor = 28 + for _ in range(struct.unpack_from(' Date: Fri, 6 Nov 2026 14:31:27 -0600 Subject: [PATCH 10/16] apple: Bind ARM32 recovery to its checked host handoff Carry a fresh nonce and the original public template hash through the payload, USB identity, serial bootstrap and authenticated SSH status. Require a private completed attempt receipt when resuming this host-bound recovery. Assisted-by: LLM --- docs/adapter-contract.md | 12 ++- src/orchard/apple/cascadia.py | 71 ++++++++++++- src/orchard/apple/device.py | 6 +- src/orchard/apple/linux_usb.py | 3 + src/orchard/apple/usb.py | 2 + src/orchard/apple/usb_identity.py | 13 +++ src/orchard/contracts.py | 31 ++++++ src/orchard/transports/ssh.py | 27 +++-- tests/test_host_handoff.py | 165 ++++++++++++++++++++++++++++++ tests/test_recovery_autogo.py | 162 +++++++++++++++++++++++------ tests/test_usb_handoff.py | 62 +++++++++++ 11 files changed, 511 insertions(+), 43 deletions(-) create mode 100644 src/orchard/apple/usb_identity.py create mode 100644 tests/test_host_handoff.py create mode 100644 tests/test_usb_handoff.py diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index 6eb2a554..3acbf42b 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -20,13 +20,23 @@ 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. +- `Connection`: SSH arguments, validated `BootState` and a current-endpoint check performed before every RPC. A host-bound ARM32 recovery also carries its `HostHandoff`. Private state is keyed by profile/ECID/boot. +- `HostHandoff`: the attempt's 128-bit nonce, template SHA-256 and observed chip/board IDs. Serial bootstrap and authenticated SSH status must report these fields with `identity_source=host-usb`; the USB endpoint must retain the matching `HND` nonce 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 hash and the hash of the bound upload in a private +receipt. Resuming a USB recovery that advertises `HND` requires its completed +receipt with the same profile, ECID and Linux boot ID. The serial and SSH checks +then compare the runtime's provenance to that receipt before granting phase RPC +access. ARM64 ADT identity and caller-owned ARM32 payloads without this policy +keep their existing identity 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/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py index ef9a66a7..d36e6bf2 100644 --- a/src/orchard/apple/cascadia.py +++ b/src/orchard/apple/cascadia.py @@ -19,7 +19,9 @@ import zlib from orchard.integrity import digest -from orchard.state import DeviceState, private_directory +from orchard.contracts import HostHandoff +from orchard.image import cascadia as payload_image +from orchard.state import BootState, DeviceState, private_directory from . import iboot32, usb CONFIG_AT = 0x7f00 @@ -72,6 +74,14 @@ def bundle(path, expected, selected): 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) + 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) @@ -85,7 +95,34 @@ def bundle(path, expected, selected): or watchdog % 4 or l2 % 4 or initrd_address % 4096 or struct.unpack_from(' 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 = HostHandoff(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('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): @@ -149,6 +186,10 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): current = usb.recovery(product, profile) if current['ecid'] != device['ecid'] or current['boot_id'] != device.get('boot_id'): raise ValueError('Selected running recovery changed') + if current.get('handoff_id') != device.get('handoff_id'): + raise ValueError('Selected running recovery handoff changed') + BootState(DeviceState(profile, current['ecid']), current['boot_id']) + recovery_handoff(profile, current) return if device['mode'] not in ('dfu', 'recovery') or (device.get('chip'), device.get('board')) != (chip, board): raise ValueError('Enter the selected board in kDFU or iBEC Recovery first') @@ -158,11 +199,20 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): recovery = Path(assets['recovery']) expected = manifest['assets']['recovery']['sha256'] payload = bundle(recovery, expected, recipe) + policy = payload_image.host_identity_policy(payload) observed, queried = selected(device, executable, mode=device['mode'], product=recipe['device']) if device['mode'] == 'dfu' and not observed.get('pwned'): raise ValueError('Plain DFU is unsupported here; enter a pwned iBSS or iBEC Recovery first') folder = private_directory(DeviceState(profile, device['ecid']).root / 'boot-attempts') identifier = secrets.token_hex(16) + handoff = None + if policy is not None: + handoff = HostHandoff(identifier, expected, chip, board) + payload = payload_image.bind_host_identity(payload, ecid=device['ecid'], chip=chip, + board=board, profile=profile, + handoff_id=identifier, template_sha256=expected) + validate_bundle(payload, recipe) + upload_hash = hashlib.sha256(payload).hexdigest() attempt = folder / (identifier + '.json') image_copy = folder / (identifier + '-recovery.bin') stage_copy = folder / (identifier + '-ibec.img3') @@ -170,10 +220,13 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): 'build': recipe['build'], 'recovery_sha256': expected, 'payload_format': 'cascadia-cb32', 'ibec_sha256': source_receipt['ibec']['sha256'], 'phase': 'validated'} + record['recovery_sha256'] = upload_hash + if handoff is not None: + record.update(handoff.fields()) try: # Freeze inputs so another checkout rebuilding a shared component cannot # replace an upload after this operation has validated it. - for data, path, checksum in ((payload, image_copy, expected), + for data, path, checksum in ((payload, image_copy, upload_hash), (stage.read_bytes(), stage_copy, record['ibec_sha256'])): if hashlib.sha256(data).hexdigest() != checksum: raise ValueError('Boot input changed while creating its private snapshot') @@ -190,7 +243,7 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): record['phase'] = 'recovery-upload-outcome-unknown' save(attempt, record) progress.phase('Uploading ARM32 recovery') - upload(device, image_copy, executable, product=recipe['device'], expected=expected) + upload(device, image_copy, executable, product=recipe['device'], expected=upload_hash) record['phase'] = 'awaiting-linux' save(attempt, record) deadline = time.monotonic() + 120 @@ -207,6 +260,16 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): 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 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) diff --git a/src/orchard/apple/device.py b/src/orchard/apple/device.py index 9ae7339d..d28f36fe 100644 --- a/src/orchard/apple/device.py +++ b/src/orchard/apple/device.py @@ -59,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. @@ -80,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}') diff --git a/src/orchard/apple/linux_usb.py b/src/orchard/apple/linux_usb.py index b400789c..4c3fc504 100644 --- a/src/orchard/apple/linux_usb.py +++ b/src/orchard/apple/linux_usb.py @@ -3,6 +3,8 @@ import re import uuid +from .usb_identity import handoff_id + # 0x4142: Orchard Recovery; 0x4145: an installed system (orchard's initrd). MODES = {0x1280: 'recovery', 0x1281: 'recovery', 0x1282: 'recovery', 0x1283: 'recovery', 0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system'} @@ -30,6 +32,7 @@ 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), 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 or bool(re.search(r'\bPWND:\[(?:checkm8|ipwnder|iPwnder32)\]', serial)), profile=profile.group(1) if profile else None, product=product, diff --git a/src/orchard/apple/usb.py b/src/orchard/apple/usb.py index a90f050b..f2a8ef14 100644 --- a/src/orchard/apple/usb.py +++ b/src/orchard/apple/usb.py @@ -6,6 +6,7 @@ import uuid from .linux_usb import devices as linux_devices, m1n1_ports as linux_m1n1_ports +from .usb_identity import handoff_id from orchard.state import EndpointUnavailable # 0x4144: a RAM payload's USB session (orchard/usb-session). @@ -54,6 +55,7 @@ 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), '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, diff --git a/src/orchard/apple/usb_identity.py b/src/orchard/apple/usb_identity.py new file mode 100644 index 00000000..8c6bd95b --- /dev/null +++ b/src/orchard/apple/usb_identity.py @@ -0,0 +1,13 @@ +"""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'(? 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 + 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': 'host-usb', '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[HostHandoff] = None @runtime_checkable diff --git a/src/orchard/transports/ssh.py b/src/orchard/transports/ssh.py index 9f3a76bd..7651b0c4 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,14 @@ 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)): raise ValueError('Selected device/boot or USB endpoint changed') return check @@ -101,6 +104,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 +139,16 @@ 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 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 +162,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 +174,8 @@ 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/test_host_handoff.py b/tests/test_host_handoff.py new file mode 100644 index 00000000..cc7702fe --- /dev/null +++ b/tests/test_host_handoff.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""A host-bound recovery keeps its attempt identity through serial and SSH.""" +import base64 +import json +import os +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import Mock, patch + +from orchard.apple import cascadia +from orchard.contracts import Connection, HostHandoff +from orchard.state import BootState, DeviceState, private_directory +from orchard.transports import ssh + + +BOOT = '11111111-1111-4111-8111-111111111111' +NONCE = '0123456789abcdef0123456789abcdef' +TEMPLATE = 'a' * 64 + + +class HandoffChecks(unittest.TestCase): + def setUp(self): + self.handoff = HostHandoff(NONCE, TEMPLATE, 0x8942, 10) + self.scope = BootState(DeviceState('p105', 123), BOOT) + self.endpoint = {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, + 'location': 'port', 'serial_port': '/dev/fake', 'handoff_id': NONCE} + self.identity = {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, **self.handoff.fields()} + + def test_identity_requires_nonce_template_provenance_and_exact_integer_ids(self): + self.assertTrue(self.handoff.matches(self.identity)) + for field, value in (('identity_source', 'adt'), ('handoff_id', 'f' * 32), + ('template_sha256', 'b' * 64), ('chip', True), ('board', False)): + with self.subTest(field=field): + self.assertFalse(self.handoff.matches({**self.identity, field: value})) + missing = dict(self.identity) + del missing[field] + self.assertFalse(self.handoff.matches(missing)) + for nonce in ('0' * 32, 'A' * 32, 'f' * 31): + with self.subTest(nonce=nonce), self.assertRaises(ValueError): + HostHandoff(nonce, TEMPLATE, 0x8942, 10) + + def test_serial_bootstrap_checks_provenance_before_accepting_key(self): + result = {**self.identity, 'host': '172.16.42.1', 'port': 22, + 'host_key': 'ssh-ed25519 ' + 'A' * 68} + def response(value): + return json.dumps({'protocol': 1, 'event': 'result', 'operation': 'bootstrap', 'result': value}) + self.assertEqual(ssh.bootstrap_reply(0, response(result), self.scope, self.handoff)[:2], + ('172.16.42.1', 22)) + for field in self.handoff.fields(): + wrong = dict(result) + del wrong[field] + with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'host-bound handoff'): + ssh.bootstrap_reply(0, response(wrong), self.scope, self.handoff) + + def test_usb_nonce_change_stops_rpc_before_dispatch(self): + for value in (None, 'f' * 32): + current = {**self.endpoint, 'handoff_id': value} + check = ssh.endpoint_check(self.scope, self.endpoint, Mock(return_value=current), self.handoff) + connection = Connection(['ssh'], self.scope, check, self.handoff) + from orchard.protocol import Client + with patch('orchard.protocol.subprocess.run') as launch: + with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'changed'): + Client(connection, Mock()).call('start') + launch.assert_not_called() + + def test_authenticated_status_checks_template_before_yielding_connection(self): + check = Mock() + connection = Connection(['ssh'], self.scope, check, self.handoff) + for change in ({'identity_source': 'adt'}, {'handoff_id': None}, {'template_sha256': 'b' * 64}, {'board': 11}): + with patch.object(ssh, 'Client') as client: + client.return_value.call.return_value = {**self.identity, **change} + with self.subTest(change=change), self.assertRaisesRegex(ValueError, 'host-bound handoff'): + ssh.verify_peer(connection) + check.assert_not_called() + with patch.object(ssh, 'Client') as client: + client.return_value.call.return_value = self.identity + ssh.verify_peer(connection) + check.assert_called_once() + + def test_session_rejects_unknown_or_changed_handoff_before_credentials(self): + for handoff, endpoint in ((None, self.endpoint), + (self.handoff, {**self.endpoint, 'handoff_id': None})): + with patch.object(ssh.subprocess, 'run') as launch: + with self.subTest(handoff=handoff), self.assertRaisesRegex(ValueError, 'handoff'): + with ssh.session(self.scope.device, Mock(return_value=endpoint), handoff=handoff): + self.fail('Unbound session opened') + launch.assert_not_called() + + def test_session_echoes_bound_identity_and_verifies_peer_before_yield(self): + def keygen(argv, **kwargs): + path = Path(argv[-1]) + path.write_text('private fixture') + path.with_suffix('.pub').write_text('ssh-ed25519 ' + 'A' * 68 + ' fixture\n') + return Mock(returncode=0) + captured = [] + def bootstrap(command, **kwargs): + captured.append(json.loads(base64.urlsafe_b64decode(command.rsplit(' ', 1)[1]))) + result = {**self.identity, 'host': '172.16.42.1', 'port': 22, + 'host_key': 'ssh-ed25519 ' + 'A' * 68} + return 0, json.dumps({'protocol': 1, 'event': 'result', 'operation': 'bootstrap', 'result': result}) + with TemporaryDirectory() as temporary, patch.dict(os.environ, {'ORCHARD_HOME': temporary}), \ + patch.object(ssh.subprocess, 'run', side_effect=keygen), \ + patch.object(ssh, 'SerialPort') as serial, patch.object(ssh, 'wait_authenticated'), \ + patch.object(ssh, 'Client') as client: + serial.return_value.__enter__.return_value.run.side_effect = bootstrap + client.return_value.call.return_value = self.identity + with ssh.session(self.scope.device, Mock(return_value=self.endpoint), + expected_boot=BOOT, handoff=self.handoff) as connection: + self.assertEqual(connection.handoff, self.handoff) + client.return_value.call.assert_called_once_with('status', timeout=8) + self.assertEqual({key: captured[0][key] for key in self.handoff.fields()}, self.handoff.fields()) + + +class CompletedHandoff(unittest.TestCase): + def setUp(self): + self.temp = TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + env = patch.dict(os.environ, {'ORCHARD_HOME': str(self.root)}) + env.start() + self.addCleanup(env.stop) + self.endpoint = {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, 'handoff_id': NONCE} + self.handoff = HostHandoff(NONCE, TEMPLATE, 0x8942, 10) + folder = private_directory(DeviceState('p105', 123).root / 'boot-attempts') + self.path = folder / (NONCE + '.json') + self.record = {'schema': 1, 'method': 'recovery-autogo', 'phase': 'complete', + 'payload_format': 'cascadia-cb32', 'profile': 'p105', 'ecid': 123, + 'boot_id': BOOT, **self.handoff.fields()} + + def save(self): + self.path.write_text(json.dumps(self.record)) + self.path.chmod(0o600) + + def test_completed_private_receipt_recovers_exact_handoff(self): + self.save() + self.assertEqual(cascadia.recovery_handoff('p105', self.endpoint), self.handoff) + self.assertIsNone(cascadia.recovery_handoff('p105', {**self.endpoint, 'handoff_id': None})) + + def test_unknown_incomplete_or_changed_receipt_cannot_resume(self): + with self.assertRaisesRegex(ValueError, 'private completed'): + cascadia.recovery_handoff('p105', self.endpoint) + for change in ({'phase': 'awaiting-linux'}, {'identity_source': 'adt'}, {'handoff_id': 'f' * 32}, + {'ecid': 124}, {'profile': 'j71b'}, {'boot_id': '22222222-2222-4222-8222-222222222222'}): + original = self.record + self.record = {**original, **change} + self.save() + with self.subTest(change=change), self.assertRaisesRegex(ValueError, 'handoff receipt'): + cascadia.recovery_handoff('p105', self.endpoint) + self.record = original + + def test_linked_or_public_receipt_is_refused(self): + self.save() + self.path.chmod(0o644) + with self.assertRaisesRegex(ValueError, 'private completed'): + cascadia.recovery_handoff('p105', self.endpoint) + target = self.path.with_suffix('.other') + self.path.rename(target) + self.path.symlink_to(target) + with self.assertRaisesRegex(ValueError, 'private completed'): + cascadia.recovery_handoff('p105', self.endpoint) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_recovery_autogo.py b/tests/test_recovery_autogo.py index 80fc36fd..344aee89 100644 --- a/tests/test_recovery_autogo.py +++ b/tests/test_recovery_autogo.py @@ -1,5 +1,4 @@ # SPDX-License-Identifier: GPL-2.0-only -import copy import hashlib import json from pathlib import Path @@ -7,10 +6,11 @@ from tempfile import TemporaryDirectory import unittest from unittest.mock import Mock, patch -import zlib from orchard.apple import cascadia, iboot32 +BOOT_ID = '11111111-1111-4111-8111-111111111111' +HANDOFF_ID = '0123456789abcdef0123456789abcdef' def img3(payload, encrypted=True): tag = iboot32.TAG @@ -34,17 +34,9 @@ def fixture(): def payload(): - size = 0x8200 - data = bytearray(size) - data[:4] = bytes.fromhex('c0000cf1') - struct.pack_into(' Date: Fri, 6 Nov 2026 14:38:47 -0600 Subject: [PATCH 11/16] cascadia: Share payload binding with the installed host core Keep the CB32 and identity codec in a standard-library core module so recovery-autogo works in the source-denied installer. The existing image command remains the producer entry point, and its cache inputs include the shared code. Exercise a real host identity bind from an unpacked core without site packages or image producers. Assisted-by: LLM --- boot/cascadia/build | 2 +- src/orchard/apple/cascadia.py | 2 +- src/orchard/build.py | 3 +- src/orchard/cascadia_payload.py | 486 ++++++++++++++++++++++++++++++++ src/orchard/image/cascadia.py | 485 +------------------------------ src/orchard/image/recovery.py | 2 +- src/orchard/ram.py | 2 +- tests/test_armv7_producers.py | 4 +- tests/test_cascadia_loader.py | 2 +- tests/test_core.py | 32 +++ tests/test_public_recovery.py | 2 +- 11 files changed, 532 insertions(+), 490 deletions(-) create mode 100644 src/orchard/cascadia_payload.py diff --git a/boot/cascadia/build b/boot/cascadia/build index 10e647c6..9a1c927f 100755 --- a/boot/cascadia/build +++ b/boot/cascadia/build @@ -9,7 +9,7 @@ import sys ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / 'src')) from orchard import buildenv -from orchard.image.cascadia import digest, profile, verify_loader +from orchard.cascadia_payload import digest, profile, verify_loader def main(): diff --git a/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py index d36e6bf2..33afaa26 100644 --- a/src/orchard/apple/cascadia.py +++ b/src/orchard/apple/cascadia.py @@ -20,7 +20,7 @@ from orchard.integrity import digest from orchard.contracts import HostHandoff -from orchard.image import cascadia as payload_image +from orchard import cascadia_payload as payload_image from orchard.state import BootState, DeviceState, private_directory from . import iboot32, usb diff --git a/src/orchard/build.py b/src/orchard/build.py index a11eeecc..4e5aa098 100644 --- a/src/orchard/build.py +++ b/src/orchard/build.py @@ -558,7 +558,8 @@ def prepare_payload_loader(hardware=None, cache=None, *, device): raise ValueError('Unsupported payload loader: ' + kind) from .hardware import payload_profile profile = payload_profile(device, 'cascadia', hardware) - inputs = ['boot/cascadia', 'src/orchard/image/cascadia.py', str(profile.relative_to(hardware)), + inputs = ['boot/cascadia', 'src/orchard/image/cascadia.py', 'src/orchard/cascadia_payload.py', + 'src/orchard/recovery_identity.py', str(profile.relative_to(hardware)), 'src/orchard/buildenv.py'] def produce(stage): command(hardware / 'boot/cascadia/build', '--profile', profile, '--output', stage) diff --git a/src/orchard/cascadia_payload.py b/src/orchard/cascadia_payload.py new file mode 100644 index 00000000..344785d7 --- /dev/null +++ b/src/orchard/cascadia_payload.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Assemble an ARM32 iBEC payload with an external initramfs described by its DTB. + +The loader occupies the first 32 KiB. Its checked configuration tells the +loader where to copy the initramfs before entering the unchanged zImage. +This module uses only the standard library and also runs by file path. +""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +import sys +import tomllib +import zlib + +from . import recovery_identity as identity_protocol + + +CONFIG_OFFSET = 0x7f00 +CONFIG = struct.Struct('<4s16I') +CONFIG_FIELDS = ('magic', 'version', 'header_size', 'load_address', 'kernel_offset', + 'dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', + 'initrd_address', 'image_size', 'watchdog_address', 'l2_address', + 'ram_start', 'ram_end', 'initrd_limit', 'crc32') +PROFILE_FIELDS = frozenset(('schema', 'load_address', 'kernel_offset', 'watchdog_address', + 'l2_address', 'ram_start', 'ram_end', 'initrd_address', 'initrd_limit')) +FDT_HEADER = struct.Struct('>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 > 0x6000: + 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.""" + policy = host_identity_policy(data) + if policy is None: + raise ValueError('Payload does not declare host-bound recovery identity') + 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': 'host-usb', + '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/image/cascadia.py b/src/orchard/image/cascadia.py index d3c899de..4a39f7ad 100644 --- a/src/orchard/image/cascadia.py +++ b/src/orchard/image/cascadia.py @@ -1,490 +1,13 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-only -"""Assemble an ARM32 iBEC payload with an external initramfs described by its DTB. - -The loader occupies the first 32 KiB. Its checked configuration tells the -loader where to copy the initramfs before entering the unchanged zImage. -This module uses only the standard library and also runs by file path. -""" -import argparse -import hashlib -import json +"""Command entry point for the shared standard-library CB32 payload codec.""" from pathlib import Path -import struct import sys -import tomllib -import zlib - -if __package__: - from .. import recovery_identity as identity_protocol -else: # The distribution contract also runs this producer by file path. - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - import recovery_identity as identity_protocol - - -CONFIG_OFFSET = 0x7f00 -CONFIG = struct.Struct('<4s16I') -CONFIG_FIELDS = ('magic', 'version', 'header_size', 'load_address', 'kernel_offset', - 'dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', - 'initrd_address', 'image_size', 'watchdog_address', 'l2_address', - 'ram_start', 'ram_end', 'initrd_limit', 'crc32') -PROFILE_FIELDS = frozenset(('schema', 'load_address', 'kernel_offset', 'watchdog_address', - 'l2_address', 'ram_start', 'ram_end', 'initrd_address', 'initrd_limit')) -FDT_HEADER = struct.Struct('>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 > 0x6000: - 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.""" - policy = host_identity_policy(data) - if policy is None: - raise ValueError('Payload does not declare host-bound recovery identity') - 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': 'host-usb', - '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 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/recovery.py b/src/orchard/image/recovery.py index 70d84a01..a9c7ef68 100644 --- a/src/orchard/image/recovery.py +++ b/src/orchard/image/recovery.py @@ -47,7 +47,7 @@ def main(): 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 import check_compatible + 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']) diff --git a/src/orchard/ram.py b/src/orchard/ram.py index c24c47dd..ad74add4 100644 --- a/src/orchard/ram.py +++ b/src/orchard/ram.py @@ -72,7 +72,7 @@ def assemble_ram(hardware, output, *, kernel, busybox, loader, pongo, init, dtbs = [kernel / 'dtbs' / (dtb + '.dtb') for dtb in boards.values()] if profile is not None: from .hardware import device as described_device - from .image.cascadia import check_compatible + from .cascadia_payload import check_compatible check_compatible(dtbs[0].read_bytes(), described_device(next(iter(boards)), hardware)['device']['dt_compatible']) inputs = [init, busybox, loader, kernel / described['image'], *dtbs, diff --git a/tests/test_armv7_producers.py b/tests/test_armv7_producers.py index 166a8135..0f502f6a 100644 --- a/tests/test_armv7_producers.py +++ b/tests/test_armv7_producers.py @@ -208,7 +208,7 @@ def test_runtime_refuses_foreign_architecture_library(self): def test_armv7_ram_payload_uses_the_selected_profile_without_pongo(self): from orchard import ram - from orchard.image import cascadia + from orchard import cascadia_payload as cascadia from test_cascadia_loader import loader, kernel, tree bundle = self.work / 'kernel' (bundle / 'dtbs').mkdir(parents=True) @@ -237,7 +237,7 @@ def test_armv7_ram_payload_uses_the_selected_profile_without_pongo(self): ram.verify_ram(output) def test_cascadia_profile_requires_matching_dtb_compatible(self): - from orchard.image import cascadia + from orchard import cascadia_payload as cascadia from test_cascadia_loader import tree data = cascadia.write_fdt(tree(), [], 0) with self.assertRaisesRegex(ValueError, 'differs from the selected board'): diff --git a/tests/test_cascadia_loader.py b/tests/test_cascadia_loader.py index 7f8bae26..e7cc86d2 100644 --- a/tests/test_cascadia_loader.py +++ b/tests/test_cascadia_loader.py @@ -11,7 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / 'src')) -from orchard.image import cascadia +from orchard import cascadia_payload as cascadia PROFILE = dict(schema=1, load_address=0x80000000, kernel_offset=0x8000, diff --git a/tests/test_core.py b/tests/test_core.py index 1f41b2f3..baf68d09 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -38,6 +38,8 @@ def test_the_core_imports_only_the_standard_library_and_itself(self): core = {module_name(name) for name in files} self.assertIn('orchard.user', core) self.assertIn('orchard.service', core) + self.assertIn('orchard.cascadia_payload', core) + self.assertIn('orchard.recovery_identity', core) self.assertNotIn('orchard.build', core) for name, path in files.items(): module = module_name(name) @@ -88,6 +90,36 @@ def run_core(self, archive, *arguments): class Zipapp(Store): + def test_public_cascadia_binding_runs_without_source_or_site_packages(self): + from orchard import cascadia_payload, recovery_identity + from test_cascadia_loader import PROFILE, kernel, loader, tree + policy = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', + 'compatibles': ['apple,p105ap'], 'chip': 0x8942, 'board': 10} + dtb = tree() + dtb['properties']['compatible'] = b'apple,p105ap\0' + data, _ = cascadia_payload.assemble(loader(), kernel(), cascadia_payload.write_fdt(dtb), + b'cpio', PROFILE, recovery_identity.encode_parameter(recovery_identity.POLICY_PARAMETER, policy)) + template, output = self.home / 'template.bin', self.home / 'bound.bin' + template.write_bytes(data) + archive = self.home / 'orchard-core.pyz' + installer.build(archive, loaders=self.loaders, boards=['j71b']) + self.assertEqual(self.run_core(archive, 'version').returncode, 0) + core, = (self.home / 'store/core').iterdir() + self.assertFalse((core / 'src/orchard/image').exists()) + script = ('import sys; from pathlib import Path; sys.path.insert(0,sys.argv[1]); ' + 'from orchard import cascadia_payload as p; ' + 'data=Path(sys.argv[2]).read_bytes(); ' + 'bound=p.bind_host_identity(data,ecid=123,chip=0x8942,board=10,profile="p105",' + 'handoff_id="0123456789abcdef0123456789abcdef",template_sha256=p.digest(data)); ' + 'Path(sys.argv[3]).write_bytes(bound)') + result = subprocess.run([sys.executable, '-I', '-S', '-c', script, str(core / 'src'), + str(template), str(output)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + config = cascadia_payload.configuration(output.read_bytes()) + self.assertEqual(config['image_size'], output.stat().st_size) + self.assertEqual(template.read_bytes(), data) + self.assertGreater(output.stat().st_size, len(data)) + def test_runs_without_site_packages_and_unpacks_once(self): archive = self.home / 'orchard-core.pyz' receipt = installer.build(archive, loaders=self.loaders, boards=['j71b']) diff --git a/tests/test_public_recovery.py b/tests/test_public_recovery.py index 3a484bb0..27c7c21b 100644 --- a/tests/test_public_recovery.py +++ b/tests/test_public_recovery.py @@ -4,7 +4,7 @@ from orchard import recovery_identity as protocol from orchard.recovery_policy import settings -from orchard.image import cascadia +from orchard import cascadia_payload as cascadia from test_cascadia_loader import PROFILE, loader, kernel, tree POLICY = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', From 877497013cf722f04e177f17302f1e2a2d215354 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:42:56 -0600 Subject: [PATCH 12/16] tests: Isolate the handoff fixture from SSH connection reuse Mock the current upstream connection context so the serial/bootstrap test keeps exercising the handoff fields without starting a transport. Assisted-by: LLM --- src/orchard/transports/ssh.py | 2 -- tests/test_host_handoff.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/orchard/transports/ssh.py b/src/orchard/transports/ssh.py index 7651b0c4..2b52ddfb 100644 --- a/src/orchard/transports/ssh.py +++ b/src/orchard/transports/ssh.py @@ -177,5 +177,3 @@ def session(device: DeviceState, select_endpoint, *, expected_boot=None, handoff connection = Connection(argv, scope, check, handoff) verify_peer(connection) yield connection - - diff --git a/tests/test_host_handoff.py b/tests/test_host_handoff.py index cc7702fe..e0e78a05 100644 --- a/tests/test_host_handoff.py +++ b/tests/test_host_handoff.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: GPL-2.0-only """A host-bound recovery keeps its attempt identity through serial and SSH.""" import base64 +from contextlib import nullcontext import json import os from pathlib import Path @@ -102,6 +103,7 @@ def bootstrap(command, **kwargs): with TemporaryDirectory() as temporary, patch.dict(os.environ, {'ORCHARD_HOME': temporary}), \ patch.object(ssh.subprocess, 'run', side_effect=keygen), \ patch.object(ssh, 'SerialPort') as serial, patch.object(ssh, 'wait_authenticated'), \ + patch.object(ssh, 'one_connection', return_value=nullcontext(['ssh'])), \ patch.object(ssh, 'Client') as client: serial.return_value.__enter__.return_value.run.side_effect = bootstrap client.return_value.call.return_value = self.identity From 83d3adbbb3712bc7f7a7b796c5d2c200bb3690da Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:57:26 -0600 Subject: [PATCH 13/16] apple: start Cascadia recovery with a checked trigger Send the identity-bound CB32 bundle before its exact loader prefix so the prepared iBEC enters the complete image already in RAM. Keep separate durable uncertainty phases and bind both upload hashes to the completed receipt. Require an observable irecovery completion marker and reject Recovery payload lengths whose trailing USB packet cannot be proved. Document the two-part contract and cover template, bound-bundle and trigger failure boundaries. Assisted-by: LLM --- docs/adapter-contract.md | 6 +- docs/boot-contract.md | 9 ++- docs/platforms-and-builds.md | 12 ++-- src/orchard/apple/cascadia.py | 46 +++++++++++-- src/orchard/contracts.py | 2 +- tests/test_host_handoff.py | 15 ++++- tests/test_recovery_autogo.py | 122 ++++++++++++++++++++++++++++------ 7 files changed, 174 insertions(+), 38 deletions(-) diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index 3acbf42b..96c32340 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -30,8 +30,10 @@ TypedDict records describe the boundary. They do not replace validation of untru 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 hash and the hash of the bound upload in a private -receipt. Resuming a USB recovery that advertises `HND` requires its completed +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. Resuming a USB recovery that +advertises `HND` requires its completed receipt with the same profile, ECID and Linux boot ID. The serial and SSH checks then compare the runtime's provenance to that receipt before granting phase RPC access. ARM64 ADT identity and caller-owned ARM32 payloads without this policy diff --git a/docs/boot-contract.md b/docs/boot-contract.md index 290d9bec..9dcac9d1 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -220,8 +220,13 @@ ECID, CPID, BDID and product. It then binds one nonzero random 128-bit also contains `schema`, `source`, `profile`, `ecid`, `chip` and `board`. The transport rebuilds the checked CB32 configuration and FDT while retaining the kernel and initramfs bytes, then rechecks the selected endpoint immediately -before its single upload. Its private attempt receipt records both image -hashes and the nonce. +before sending the bound bundle. A second Recovery upload carries the exact +checked loader prefix through the CB32 configuration record, which makes the +prepared iBEC enter the complete bundle already in RAM. 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, +the loader-prefix byte count and the nonce. The runtime requires the live compatible, policy token and staged policy to agree. It publishes `identity_source = "host-usb"`, the nonce and original diff --git a/docs/platforms-and-builds.md b/docs/platforms-and-builds.md index d302aa1c..f2871d8b 100644 --- a/docs/platforms-and-builds.md +++ b/docs/platforms-and-builds.md @@ -493,11 +493,13 @@ 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. The small second upload makes iBEC enter the bundle + already stored at `0x80000000`. +4. **Staging bundle** at `0x80000000`: the `linux_boot` trampoline paints + status and turns MMU and caches off. 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/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py index 33afaa26..28189528 100644 --- a/src/orchard/apple/cascadia.py +++ b/src/orchard/apple/cascadia.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: GPL-2.0-only -"""One-shot ARM32 recovery upload through a prepared iBEC autogo chain. +"""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 @@ -26,6 +26,8 @@ CONFIG_AT = 0x7f00 CONFIG = struct.Struct('<4s16I') +AUTOGO_TRIGGER_LIMIT = 0x8000 +RECOVERY_PACKET_BYTES = 512 def tool(): @@ -62,7 +64,7 @@ def query(device, executable): def bundle(path, expected, selected): - """Validate the complete single-upload CB32 payload before device access.""" + """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') @@ -75,6 +77,7 @@ def bundle(path, expected, selected): 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 @@ -97,6 +100,24 @@ def validate_bundle(data, selected): raise ValueError('Recovery loader ranges differ from the selected platform') +def loader_trigger(data, candidate=None): + """Return the exact small loader prefix that makes the patched iBEC run it.""" + size = CONFIG_AT + CONFIG.size + trigger = data[:size] if candidate is None else candidate + if (size >= AUTOGO_TRIGGER_LIMIT 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') @@ -119,6 +140,9 @@ def recovery_handoff(profile, endpoint): 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') != CONFIG_AT + CONFIG.size or + not isinstance(record.get('loader_sha256'), str) or + not re.fullmatch('[0-9a-f]{64}', record['loader_sha256']) 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') @@ -172,12 +196,15 @@ def wait_recovery(device, executable, *, product, seconds=30): 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: + if (result.returncode or + not re.search(rb'(? Date: Fri, 6 Nov 2026 17:03:32 -0600 Subject: [PATCH 14/16] apple: harden the Cascadia trigger handoff Send the checked loader code prefix after iBEC has had one monotonic second to rearm its Recovery endpoint. Revalidate the exact device before the trigger and retain each transition in the private attempt receipt. Keep the trigger below the iBEC auto-go cutoff while preserving the bound configuration in RAM. Assisted-by: LLM --- docs/adapter-contract.md | 9 +-- docs/boot-contract.md | 12 ++-- docs/platforms-and-builds.md | 7 ++- src/orchard/apple/cascadia.py | 34 +++++++++-- src/orchard/cascadia_payload.py | 3 +- tests/test_host_handoff.py | 12 +++- tests/test_recovery_autogo.py | 102 +++++++++++++++++++++++++++++++- 7 files changed, 157 insertions(+), 22 deletions(-) diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index 96c32340..d1445665 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -32,10 +32,11 @@ 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. Resuming a USB recovery that -advertises `HND` requires its completed -receipt with the same profile, ECID and Linux boot ID. The serial and SSH checks -then compare the runtime's provenance to that receipt before granting phase RPC +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 +USB recovery that advertises `HND` requires its completed receipt with the same +profile, ECID and Linux boot ID. The serial and SSH checks then compare the +runtime's provenance to that receipt before granting phase RPC access. ARM64 ADT identity and caller-owned ARM32 payloads without this policy keep their existing identity paths. diff --git a/docs/boot-contract.md b/docs/boot-contract.md index 9dcac9d1..d31c1f2b 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -220,13 +220,17 @@ ECID, CPID, BDID and product. It then binds one nonzero random 128-bit also contains `schema`, `source`, `profile`, `ecid`, `chip` and `board`. 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. A second Recovery upload carries the exact -checked loader prefix through the CB32 configuration record, which makes the -prepared iBEC enter the complete bundle already in RAM. The transport rejects +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 then carries the exact checked loader prefix through +the loader's reserved code area. The bound configuration stays in RAM from the +first upload. This small transfer makes the prepared iBEC enter the complete +bundle already in RAM. 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, -the loader-prefix byte count and the nonce. +the loader-prefix byte count, completed settle check and nonce. The runtime requires the live compatible, policy token and staged policy to agree. It publishes `identity_source = "host-usb"`, the nonce and original diff --git a/docs/platforms-and-builds.md b/docs/platforms-and-builds.md index f2871d8b..ba1943e6 100644 --- a/docs/platforms-and-builds.md +++ b/docs/platforms-and-builds.md @@ -494,8 +494,11 @@ Project Cascadia's chain, as shaynelegault ran it on a P105: 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. The host uploads the complete bound bundle, followed by its exact - checked loader prefix. The small second upload makes iBEC enter the bundle - already stored at `0x80000000`. + checked loader prefix. It leaves at least one second for the Recovery + endpoint to rearm and rechecks the exact device between those uploads. The + small second upload covers the loader code while retaining the bound + configuration from the full bundle, then makes iBEC enter the image already + stored at `0x80000000`. 4. **Staging bundle** at `0x80000000`: the `linux_boot` trampoline paints status and turns MMU and caches off. Padding reaches `0x8000`, followed by `zImage` with the DTB appended and entry at `0x80008000`. Current Orchard diff --git a/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py index 28189528..4674fe22 100644 --- a/src/orchard/apple/cascadia.py +++ b/src/orchard/apple/cascadia.py @@ -27,7 +27,10 @@ CONFIG_AT = 0x7f00 CONFIG = struct.Struct('<4s16I') AUTOGO_TRIGGER_LIMIT = 0x8000 +LOADER_CODE_LIMIT = payload_image.LOADER_CODE_LIMIT +LOADER_TRIGGER_BYTES = LOADER_CODE_LIMIT + 1 RECOVERY_PACKET_BYTES = 512 +RECOVERY_REARM_SECONDS = 1.0 def tool(): @@ -102,9 +105,10 @@ def validate_bundle(data, selected): def loader_trigger(data, candidate=None): """Return the exact small loader prefix that makes the patched iBEC run it.""" - size = CONFIG_AT + CONFIG.size + size = LOADER_TRIGGER_BYTES trigger = data[:size] if candidate is None else candidate - if (size >= AUTOGO_TRIGGER_LIMIT or len(trigger) != size or + if (size >= 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)) @@ -140,9 +144,12 @@ def recovery_handoff(profile, endpoint): 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') != CONFIG_AT + CONFIG.size 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') @@ -172,6 +179,17 @@ def selected(device, executable, *, mode, product): 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: @@ -250,7 +268,9 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): record = {'schema': 1, 'method': 'recovery-autogo', 'ecid': device['ecid'], 'profile': profile, 'build': recipe['build'], 'recovery_sha256': expected, 'payload_format': 'cascadia-cb32', - 'ibec_sha256': source_receipt['ibec']['sha256'], 'phase': 'validated'} + 'ibec_sha256': source_receipt['ibec']['sha256'], 'phase': 'validated', + 'inter_upload_settle_seconds': RECOVERY_REARM_SECONDS, + 'loader_revalidated': False} record['recovery_sha256'] = upload_hash record['loader_sha256'] = trigger_hash record['loader_bytes'] = len(trigger) @@ -278,7 +298,11 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): save(attempt, record) progress.phase('Uploading ARM32 recovery') upload(device, image_copy, executable, product=recipe['device'], expected=upload_hash) - record['phase'] = 'loader-upload-outcome-unknown' + record['phase'] = 'loader-settle' + save(attempt, record) + progress.phase('Waiting for the ARM32 recovery endpoint') + settle_recovery(device, executable, product=recipe['device']) + record.update(phase='loader-upload-outcome-unknown', loader_revalidated=True) save(attempt, record) progress.phase('Starting the checked ARM32 recovery loader') upload(device, trigger_copy, executable, product=recipe['device'], expected=trigger_hash) diff --git a/src/orchard/cascadia_payload.py b/src/orchard/cascadia_payload.py index 344785d7..8dd263c1 100644 --- a/src/orchard/cascadia_payload.py +++ b/src/orchard/cascadia_payload.py @@ -18,6 +18,7 @@ from . import recovery_identity as identity_protocol +LOADER_CODE_LIMIT = 0x6000 CONFIG_OFFSET = 0x7f00 CONFIG = struct.Struct('<4s16I') CONFIG_FIELDS = ('magic', 'version', 'header_size', 'load_address', 'kernel_offset', @@ -98,7 +99,7 @@ def verify_loader(binary, described, elf=None): 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 > 0x6000: + 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: diff --git a/tests/test_host_handoff.py b/tests/test_host_handoff.py index e4ea9140..d5565195 100644 --- a/tests/test_host_handoff.py +++ b/tests/test_host_handoff.py @@ -128,8 +128,10 @@ def setUp(self): self.path = folder / (NONCE + '.json') self.record = {'schema': 1, 'method': 'recovery-autogo', 'phase': 'complete', 'payload_format': 'cascadia-cb32', 'profile': 'p105', 'ecid': 123, - 'boot_id': BOOT, 'loader_bytes': cascadia.CONFIG_AT + cascadia.CONFIG.size, - 'loader_sha256': 'b' * 64, **self.handoff.fields()} + 'boot_id': BOOT, 'loader_bytes': cascadia.LOADER_TRIGGER_BYTES, + 'loader_sha256': 'b' * 64, + 'inter_upload_settle_seconds': cascadia.RECOVERY_REARM_SECONDS, + 'loader_revalidated': True, **self.handoff.fields()} def save(self): self.path.write_text(json.dumps(self.record)) @@ -145,6 +147,9 @@ def test_unknown_incomplete_or_changed_receipt_cannot_resume(self): cascadia.recovery_handoff('p105', self.endpoint) for change in ({'phase': 'awaiting-linux'}, {'identity_source': 'adt'}, {'handoff_id': 'f' * 32}, {'ecid': 124}, {'profile': 'j71b'}, {'loader_bytes': 0x8000}, + {'inter_upload_settle_seconds': 0.5}, + {'inter_upload_settle_seconds': 1}, {'loader_revalidated': False}, + {'loader_revalidated': 1}, {'loader_revalidated': 'true'}, {'loader_sha256': 'B' * 64}, {'boot_id': '22222222-2222-4222-8222-222222222222'}): original = self.record @@ -153,7 +158,8 @@ def test_unknown_incomplete_or_changed_receipt_cannot_resume(self): with self.subTest(change=change), self.assertRaisesRegex(ValueError, 'handoff receipt'): cascadia.recovery_handoff('p105', self.endpoint) self.record = original - for field in ('loader_bytes', 'loader_sha256'): + for field in ('loader_bytes', 'loader_sha256', 'inter_upload_settle_seconds', + 'loader_revalidated'): original = self.record self.record = dict(original) del self.record[field] diff --git a/tests/test_recovery_autogo.py b/tests/test_recovery_autogo.py index 75b2208b..17a65902 100644 --- a/tests/test_recovery_autogo.py +++ b/tests/test_recovery_autogo.py @@ -129,8 +129,16 @@ def test_complete_bundle_is_validated_without_rewriting_payload(self): def test_loader_trigger_is_the_exact_sub_packet_bundle_prefix(self): data = payload() trigger = cascadia.loader_trigger(data) - self.assertEqual(trigger, data[:cascadia.CONFIG_AT + cascadia.CONFIG.size]) + self.assertEqual(trigger, data[:cascadia.LOADER_TRIGGER_BYTES]) + self.assertEqual(len(trigger), 0x6001) + self.assertLess(len(trigger), cascadia.CONFIG_AT) self.assertLess(len(trigger), cascadia.AUTOGO_TRIGGER_LIMIT) + self.assertNotEqual(data[cascadia.CONFIG_AT:cascadia.CONFIG_AT + 4], bytes(4)) + for offset in (cascadia.LOADER_CODE_LIMIT, cascadia.CONFIG_AT - 1): + gap = bytearray(data) + gap[offset] = 1 + with self.subTest(offset=offset), self.assertRaisesRegex(ValueError, 'bundle prefix'): + cascadia.loader_trigger(gap) with self.assertRaisesRegex(ValueError, 'bundle prefix'): cascadia.loader_trigger(data, trigger[:-1]) with self.assertRaisesRegex(ValueError, 'bundle prefix'): @@ -184,6 +192,7 @@ def setUp(self): patch.object(cascadia.usb, 'devices', return_value=[self.endpoint]), patch.object(cascadia.subprocess, 'run', return_value=Mock(returncode=0, stdout=b'100%')), patch.object(cascadia.payload_image, 'host_identity_policy', return_value=None, create=True), + patch.object(cascadia, 'settle_recovery', return_value=({'pwned': True}, {'mode': 'recovery'})), ] self.mocks = [item.start() for item in self.patches] for item in self.patches: @@ -202,9 +211,55 @@ def test_uploads_each_payload_once_and_binds_the_finished_boot(self): self.assertTrue(self.device['resumed']) receipts = list(self.attempts.glob('*.json')) self.assertEqual(len(receipts), 1) - self.assertEqual(json.loads(receipts[0].read_text())['phase'], 'complete') + record = json.loads(receipts[0].read_text()) + self.assertEqual(record['phase'], 'complete') + self.assertEqual(record['inter_upload_settle_seconds'], cascadia.RECOVERY_REARM_SECONDS) + self.assertIs(record['loader_revalidated'], True) + self.assertEqual(self.mocks[8].call_count, 1) self.assertFalse(list(self.attempts.glob('*-loader.bin'))) + def test_bundle_settle_revalidation_receipt_and_trigger_are_ordered(self): + events = [] + recovery_state = [] + real_save = cascadia.save + + def saving(path, record): + real_save(path, record) + if record['phase'] in ('loader-settle', 'loader-upload-outcome-unknown'): + events.append(('receipt', record['phase'], record['loader_revalidated'])) + + def settling(device, executable, *, product): + recovery_state.append(dict(device)) + events.append(('settle', executable, product)) + return {'pwned': True}, {'mode': 'recovery'} + + def sending(argv, **kwargs): + uploaded = Path(argv[-1]) + if not events: + events.append(('upload', 'bundle', len(uploaded.read_bytes()))) + else: + record = json.loads(next(self.attempts.glob('*.json')).read_text()) + events.append(('upload', 'loader', record['phase'], + record['loader_revalidated'], len(uploaded.read_bytes()))) + return Mock(returncode=0, stdout=b'100%') + + self.mocks[6].side_effect = sending + self.mocks[8].side_effect = settling + with patch.object(cascadia, 'save', side_effect=saving): + self.run_boot() + self.assertEqual(events, [ + ('upload', 'bundle', len(self.data)), + ('receipt', 'loader-settle', False), + ('settle', 'irecovery', 'Synthetic1,1'), + ('receipt', 'loader-upload-outcome-unknown', True), + ('upload', 'loader', 'loader-upload-outcome-unknown', True, + cascadia.LOADER_TRIGGER_BYTES), + ]) + self.assertEqual(recovery_state, [{ + 'mode': 'recovery', 'ecid': 123, 'chip': 0x8942, 'board': 10, + 'location': 456, 'resumed': False, + }]) + def test_zlp_length_stops_before_device_selection_or_upload(self): extra = -len(self.data) % cascadia.RECOVERY_PACKET_BYTES self.data = payload(b'cpio' + b'x' * extra) @@ -241,9 +296,27 @@ def test_loader_upload_without_complete_marker_is_unknown(self): receipt = next(self.attempts.glob('*.json')) record = json.loads(receipt.read_text()) self.assertEqual(record['phase'], 'loader-upload-outcome-unknown') - self.assertEqual(record['loader_bytes'], cascadia.CONFIG_AT + cascadia.CONFIG.size) + self.assertEqual(record['loader_bytes'], cascadia.LOADER_TRIGGER_BYTES) self.assertEqual(record['loader_sha256'], hashlib.sha256(cascadia.loader_trigger(self.data)).hexdigest()) + def test_settle_failure_stops_before_loader_and_retains_durable_phase(self): + def fail_after_checking_receipt(*args, **kwargs): + receipt = next(self.attempts.glob('*.json')) + record = json.loads(receipt.read_text()) + self.assertEqual(record['phase'], 'loader-settle') + self.assertEqual(record['inter_upload_settle_seconds'], cascadia.RECOVERY_REARM_SECONDS) + self.assertIs(record['loader_revalidated'], False) + self.assertEqual(self.mocks[6].call_count, 1) + raise ValueError('identity changed after settle') + + self.mocks[8].side_effect = fail_after_checking_receipt + with self.assertRaisesRegex(ValueError, 'identity changed after settle'): + self.run_boot() + self.assertEqual(self.mocks[6].call_count, 1) + record = json.loads(next(self.attempts.glob('*.json')).read_text()) + self.assertEqual(record['phase'], 'loader-settle') + self.assertIs(record['loader_revalidated'], False) + def test_failed_ibec_upload_is_not_repeated(self): self.device['mode'] = 'dfu' self.mocks[6].return_value = Mock(returncode=1, stdout=b'') @@ -455,6 +528,29 @@ def test_real_public_template_binds_two_uploads_without_changing_kernel_or_initr class QueryTests(unittest.TestCase): + def test_settle_waits_one_monotonic_second_before_exact_revalidation(self): + device = {'ecid': 123, 'chip': 0x8942, 'board': 10, 'location': 456} + now = [10.0] + sleeps = [] + + def sleep(seconds): + sleeps.append(seconds) + now[0] += 0.25 if len(sleeps) == 1 else seconds + + def checked(*args, **kwargs): + self.assertGreaterEqual(now[0], 10.0 + cascadia.RECOVERY_REARM_SECONDS) + return 'observed', 'queried' + + with patch.object(cascadia.time, 'monotonic', side_effect=lambda: now[0]), \ + patch.object(cascadia.time, 'sleep', side_effect=sleep), \ + patch.object(cascadia, 'selected', side_effect=checked) as selected: + result = cascadia.settle_recovery(device, 'irecovery', product='Synthetic1,1') + self.assertEqual(result, ('observed', 'queried')) + self.assertEqual(len(sleeps), 2) + self.assertAlmostEqual(sleeps[0], 1.0) + self.assertAlmostEqual(sleeps[1], 0.75) + selected.assert_called_once_with(device, 'irecovery', mode='recovery', product='Synthetic1,1') + def test_query_binds_all_identity_fields_and_rejects_duplicates(self): device = {'ecid': 123, 'chip': 0x8942, 'board': 10} text = 'ECID: 0x7b\nCPID: 0x8942\nBDID: 0x0a\nPRODUCT: Synthetic1,1\nMODE: Recovery\n' From f43b775c67d0284d5415452cbf72fa0330c46f3c Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:27:12 -0600 Subject: [PATCH 15/16] apple: bind on-device startup to a live iOS handoff Read the P105 identity through iOS before preparing each startup. Bind a fresh nonce and ios-device provenance into private payload and iBEC copies, then verify both renamed files before scheduling wake and entering kloader. Resume from the selected provision receipt and compare the complete handoff across recovery transports. Preserve schema-1 host-usb and policy-free ARM64 behavior. Reject malformed lease residue and persist attempt-file renames through their directories before wake. Native fixtures cover failed preparation and the iOS 9 ARMv7 link. Assisted-by: LLM --- boot/ios-launcher/README.md | 27 +++ boot/ios-launcher/ibec-local.c | 8 + boot/ios-launcher/ios-identity-bind.h | 175 ++++++++++++++++ boot/ios-launcher/ios-live-identity.h | 125 ++++++++++++ boot/ios-launcher/ios-start.c | 209 +++++++++++++++++++- devices/p105/boot.json | 6 + docs/adapter-contract.md | 18 +- docs/boot-contract.md | 94 +++++---- runtime/recovery/device.py | 2 +- runtime/recovery/identity.py | 28 ++- src/orchard/apple/cascadia.py | 16 +- src/orchard/apple/ios_identity.py | 116 +++++++++++ src/orchard/apple/ios_launcher.py | 171 ++++++++++++++-- src/orchard/apple/linux_usb.py | 3 +- src/orchard/apple/usb.py | 3 +- src/orchard/apple/usb_identity.py | 12 ++ src/orchard/cascadia_payload.py | 12 +- src/orchard/contracts.py | 11 +- src/orchard/recovery_identity.py | 30 ++- src/orchard/recovery_policy.py | 13 +- src/orchard/transports/ssh.py | 5 +- tests/fixtures/ios-attempt-files.c | 252 ++++++++++++++++++++++++ tests/fixtures/ios-attempt-wake-order.c | 222 +++++++++++++++++++++ tests/fixtures/ios-boot-lease.c | 228 +++++++++++++++++++++ tests/fixtures/ios-identity-bind.c | 149 ++++++++++++++ tests/fixtures/ios-live-identity.c | 177 +++++++++++++++++ tests/test_host_handoff.py | 20 +- tests/test_host_identity_handoff.py | 47 ++++- tests/test_ios_attempt_files.py | 193 ++++++++++++++++++ tests/test_ios_boot_lease.py | 52 +++++ tests/test_ios_identity_bind.py | 50 +++++ tests/test_ios_launch_identity.py | 178 +++++++++++++++++ tests/test_ios_live_identity.py | 47 +++++ tests/test_ios_origin_runtime.py | 75 +++++++ tests/test_public_recovery.py | 44 ++++- tests/test_usb_handoff.py | 19 ++ 36 files changed, 2739 insertions(+), 98 deletions(-) create mode 100644 boot/ios-launcher/ios-identity-bind.h create mode 100644 boot/ios-launcher/ios-live-identity.h create mode 100644 src/orchard/apple/ios_identity.py create mode 100644 tests/fixtures/ios-attempt-files.c create mode 100644 tests/fixtures/ios-attempt-wake-order.c create mode 100644 tests/fixtures/ios-boot-lease.c create mode 100644 tests/fixtures/ios-identity-bind.c create mode 100644 tests/fixtures/ios-live-identity.c create mode 100644 tests/test_ios_attempt_files.py create mode 100644 tests/test_ios_boot_lease.py create mode 100644 tests/test_ios_identity_bind.py create mode 100644 tests/test_ios_launch_identity.py create mode 100644 tests/test_ios_live_identity.py create mode 100644 tests/test_ios_origin_runtime.py diff --git a/boot/ios-launcher/README.md b/boot/ios-launcher/README.md index cd732544..20aff1d0 100644 --- a/boot/ios-launcher/README.md +++ b/boot/ios-launcher/README.md @@ -41,6 +41,33 @@ The P105 recipe keeps its existing app directory and bundle identifier so instal 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 diff --git a/boot/ios-launcher/ibec-local.c b/boot/ios-launcher/ibec-local.c index 03e38044..e4556326 100644 --- a/boot/ios-launcher/ibec-local.c +++ b/boot/ios-launcher/ibec-local.c @@ -7,6 +7,10 @@ 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) { @@ -25,7 +29,11 @@ int orchard_local_boot(void) 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. 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-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 index 841a190e..4cf924de 100644 --- a/boot/ios-launcher/ios-start.c +++ b/boot/ios-launcher/ios-start.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,10 @@ #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) { @@ -46,6 +51,176 @@ static int checked(const char *path, size_t length, const char *expected) 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; @@ -60,16 +235,25 @@ static int boot_lease(void) (long long)boot.tv_sec, (int)boot.tv_usec); if (count <= 0 || count >= (int)sizeof(token)) return -1; - int fd = open(LEASE_PATH, O_RDWR | O_CREAT | O_NOFOLLOW, 0600); - if (fd < 0) - 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)) + 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 && (strncmp(previous, "v1:", 3) || !strcmp(previous, token))) + 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; @@ -179,7 +363,11 @@ static int start_with_wake(int lease) 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); @@ -231,6 +419,11 @@ int main(int argc, char **argv) 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); @@ -243,5 +436,11 @@ int main(int argc, char **argv) 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/devices/p105/boot.json b/devices/p105/boot.json index eb0f9ff3..56c44353 100644 --- a/devices/p105/boot.json +++ b/devices/p105/boot.json @@ -59,6 +59,12 @@ "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", diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index d1445665..14260669 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -20,8 +20,8 @@ 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. A host-bound ARM32 recovery also carries its `HostHandoff`. Private state is keyed by profile/ECID/boot. -- `HostHandoff`: the attempt's 128-bit nonce, template SHA-256 and observed chip/board IDs. Serial bootstrap and authenticated SSH status must report these fields with `identity_source=host-usb`; the USB endpoint must retain the matching `HND` nonce throughout the session. +- `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. @@ -34,11 +34,15 @@ 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 -USB recovery that advertises `HND` requires its completed receipt with the same -profile, ECID and Linux boot ID. The serial and SSH checks then compare the -runtime's provenance to that receipt before granting phase RPC -access. ARM64 ADT identity and caller-owned ARM32 payloads without this policy -keep their existing identity paths. +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 diff --git a/docs/boot-contract.md b/docs/boot-contract.md index d31c1f2b..cfe1d166 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -200,54 +200,74 @@ the same m1n1 code, splash included. Which SoCs have an m1n1 storage backend is in [bring-up/bootloader/experiments.md](bring-up/bootloader/experiments.md#per-device-menu). -## Host-bound ARMv7 recovery identity +## ARMv7 recovery identity origins Cascadia recovery templates carry a descriptor-selected identity policy in the -standard `/chosen/bootargs` string. These are userland parameters, not hardware -register descriptions. `orchard.recovery-policy=VALUE` and -`orchard.recovery-handoff=VALUE` encode schema-1 JSON objects as canonical, +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 `source = "host-usb"`, canonical `profile`, accepted live FDT -`compatibles`, and integer `chip` and `board` identifiers. The public runtime -contains the same policy, generated from the selected hardware descriptor. -A 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 then binds one nonzero random 128-bit -`handoff_id` to that observation and the original `template_sha256`. The handoff -also contains `schema`, `source`, `profile`, `ecid`, `chip` and `board`. 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 then carries the exact checked loader prefix through -the loader's reserved code area. The bound configuration stays in RAM from the -first upload. This small transfer makes the prepared iBEC enter the complete -bundle already in RAM. The transport rejects +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 its reserved code +area. The bound configuration stays in RAM from the first upload, and this small +transfer starts the complete bundle already in RAM. 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, -the loader-prefix byte count, completed settle check and nonce. +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 `identity_source = "host-usb"`, the nonce and original -image digest with the kernel's fresh boot ID. This records a host observation; -it does not claim that Linux read a SoC ECID register. USB serial includes -`HND:` followed by the nonce, alongside the canonical board, ECID and boot ID. -The host requires that nonce before accepting this boot, then checks the full -provenance again through serial SSH bootstrap and authenticated status. A -later command may resume only through the matching private completed attempt -receipt, including the same profile, ECID, nonce, boot ID and template digest. -An unknown or changed handoff stops before a storage operation. +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 -host-bound policy preserves that path; an invalid host-bound policy or missing -handoff never falls back to ADT. This handoff currently serves the guarded -host RAM-boot path. On-device startup needs its own checked identity source -before it can use this public recovery runtime. +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 diff --git a/runtime/recovery/device.py b/runtime/recovery/device.py index a1954aa1..5b0b7414 100755 --- a/runtime/recovery/device.py +++ b/runtime/recovery/device.py @@ -47,7 +47,7 @@ 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') == 'host-usb' and any(type(request.get(key)) is not type(record[key]) or request.get(key) != record[key] for key in + 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', '') diff --git a/runtime/recovery/identity.py b/runtime/recovery/identity.py index ca91d859..a32befed 100644 --- a/runtime/recovery/identity.py +++ b/runtime/recovery/identity.py @@ -1,4 +1,4 @@ -"""Publish the live ADT identity or a descriptor-bound host USB boot handoff.""" +"""Publish live ADT identity or a descriptor-bound boot identity handoff.""" import json import os from pathlib import Path @@ -9,12 +9,12 @@ from adt import validate try: - from recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, object_json, + 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, object_json, + from orchard.recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, allows_source, object_json, parameters, validate_policy) RUN = Path('/run/orchard') @@ -67,7 +67,7 @@ def _compatible(policy): return first -def _host_identity(policy): +def _handoff_identity(policy): _compatible(policy) selected = parameters(_bounded(CMDLINE, MAX_IDENTITY_BYTES)) if set(selected) != {POLICY_PARAMETER, HANDOFF_PARAMETER}: @@ -75,6 +75,8 @@ def _host_identity(policy): 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') @@ -83,7 +85,7 @@ def _host_identity(policy): if not boot.int: raise ValueError('Invalid live boot ID') result = {'profile': policy['profile'], 'ecid': handoff['ecid'], 'boot_id': str(boot), - 'identity_source': 'host-usb', 'handoff_id': handoff_id, + 'identity_source': handoff['source'], 'handoff_id': handoff_id, 'template_sha256': template, 'chip': handoff['chip'], 'board': handoff['board']} serial(result) return result @@ -111,7 +113,7 @@ def cached(): policy = _policy() if policy is not None: result = object_json(_bounded(path, MAX_IDENTITY_BYTES, protected=True)) - expected = _host_identity(policy) + 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())): @@ -130,7 +132,7 @@ def initialize(): raise ValueError('Device identity was already published') policy = _policy() if policy is not None: - result = _host_identity(policy) + 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: @@ -161,8 +163,16 @@ def initialize(): def serial(record): result = f'ECID:{record["ecid"]:016x} BOOT:{record["boot_id"]} BOARD:{record["profile"]}' - if record.get('identity_source') == 'host-usb': - result += ' HND:' + record['handoff_id'] + 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 diff --git a/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py index 4674fe22..f41593c2 100644 --- a/src/orchard/apple/cascadia.py +++ b/src/orchard/apple/cascadia.py @@ -19,7 +19,7 @@ import zlib from orchard.integrity import digest -from orchard.contracts import HostHandoff +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 @@ -129,6 +129,12 @@ def recovery_handoff(profile, endpoint): 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') @@ -137,7 +143,7 @@ def recovery_handoff(profile, endpoint): raise ValueError('The host-bound recovery has no private completed attempt receipt') try: record = json.loads(path.read_text()) - handoff = HostHandoff(record['handoff_id'], record['template_sha256'], record['chip'], record['board']) + 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 @@ -233,6 +239,8 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): raise ValueError('Selected running recovery changed') if current.get('handoff_id') != device.get('handoff_id'): raise ValueError('Selected running recovery handoff changed') + if (current.get('identity_source') or 'host-usb') != (device.get('identity_source') or 'host-usb'): + raise ValueError('Selected running recovery origin changed') BootState(DeviceState(profile, current['ecid']), current['boot_id']) recovery_handoff(profile, current) return @@ -252,7 +260,7 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): identifier = secrets.token_hex(16) handoff = None if policy is not None: - handoff = HostHandoff(identifier, expected, chip, board) + handoff = RecoveryHandoff(identifier, expected, chip, board) payload = payload_image.bind_host_identity(payload, ecid=device['ecid'], chip=chip, board=board, profile=profile, handoff_id=identifier, template_sha256=expected) @@ -330,6 +338,8 @@ def boot(device, assets, manifest, progress, *, profile, chip, board, product): 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']) diff --git a/src/orchard/apple/ios_identity.py b/src/orchard/apple/ios_identity.py new file mode 100644 index 00000000..bf93fd07 --- /dev/null +++ b/src/orchard/apple/ios_identity.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Prepare a fixed-size on-device identity slot without altering Linux inputs.""" +import hashlib +import struct + +from orchard import recovery_identity +from orchard import cascadia_payload as cascadia + + +def property_span(data, wanted_path, wanted_name): + """Locate a property in an already validated FDT, excluding its padding.""" + cascadia.read_fdt(data) + _, _, tree_at, strings_at, _, _, _, _, strings_size, tree_size = cascadia.FDT_HEADER.unpack_from(data) + names = data[strings_at:strings_at + strings_size] + at, end, stack, matches = tree_at, tree_at + tree_size, [], [] + while at < end: + token = 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_launcher.py b/src/orchard/apple/ios_launcher.py index aaad496f..f90c223e 100644 --- a/src/orchard/apple/ios_launcher.py +++ b/src/orchard/apple/ios_launcher.py @@ -7,6 +7,7 @@ import argparse import hashlib import json +import os from pathlib import Path, PurePosixPath import plistlib import re @@ -117,10 +118,18 @@ def boot_profile(device, *, root=None): 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): @@ -159,6 +168,31 @@ def validate_profile(profile): 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): @@ -188,7 +222,7 @@ def patch_ibec(source, stub, profile): return bytes(changed) -def launcher_config(profile, title): +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') @@ -202,10 +236,26 @@ def launcher_config(profile, title): 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): +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. @@ -222,7 +272,9 @@ def build_shim(ibec, image, output, profile, *, clang='clang-19', linker='ld.lld ('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_boot_path']) + '\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'] @@ -262,11 +314,18 @@ def build_shim(ibec, image, output, profile, *, clang='clang-19', linker='ld.lld '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('= 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, boot_image, shim, profile, clang=clang, linker=linker, objcopy=objcopy, host_toolchain=host_toolchain) + 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(boot_image, shim / 'ibec-local.raw', kloader, native, profile, title) + 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): @@ -414,7 +488,7 @@ def stage_file(source, destination, mode): 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(boot_image, profile['boot_path'], '0644') + 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') @@ -427,10 +501,83 @@ def stage_file(source, destination, mode): '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'): diff --git a/src/orchard/apple/linux_usb.py b/src/orchard/apple/linux_usb.py index 4c3fc504..d8f2b123 100644 --- a/src/orchard/apple/linux_usb.py +++ b/src/orchard/apple/linux_usb.py @@ -3,7 +3,7 @@ import re import uuid -from .usb_identity import handoff_id +from .usb_identity import handoff_id, identity_source # 0x4142: Orchard Recovery; 0x4145: an installed system (orchard's initrd). MODES = {0x1280: 'recovery', 0x1281: 'recovery', 0x1282: 'recovery', 0x1283: 'recovery', 0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system'} @@ -33,6 +33,7 @@ def devices(root=Path('/sys/bus/usb/devices')): 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 or bool(re.search(r'\bPWND:\[(?:checkm8|ipwnder|iPwnder32)\]', serial)), profile=profile.group(1) if profile else None, product=product, diff --git a/src/orchard/apple/usb.py b/src/orchard/apple/usb.py index f2a8ef14..154540d2 100644 --- a/src/orchard/apple/usb.py +++ b/src/orchard/apple/usb.py @@ -6,7 +6,7 @@ import uuid from .linux_usb import devices as linux_devices, m1n1_ports as linux_m1n1_ports -from .usb_identity import handoff_id +from .usb_identity import handoff_id, identity_source from orchard.state import EndpointUnavailable # 0x4144: a RAM payload's USB session (orchard/usb-session). @@ -56,6 +56,7 @@ def devices(): '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, diff --git a/src/orchard/apple/usb_identity.py b/src/orchard/apple/usb_identity.py index 8c6bd95b..fb3eb9cc 100644 --- a/src/orchard/apple/usb_identity.py +++ b/src/orchard/apple/usb_identity.py @@ -11,3 +11,15 @@ def handoff_id(serial: str) -> str | None: if len(fields) != 1 or value is None: raise ValueError('Malformed or duplicate USB handoff identity') return value.group(1) + + +def identity_source(serial: str) -> 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'(? None: if (not isinstance(self.handoff_id, str) or @@ -81,12 +83,13 @@ def __post_init__(self) -> None: 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': 'host-usb', 'handoff_id': self.handoff_id, + 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: @@ -100,7 +103,7 @@ class Connection: argv: List[str] scope: BootState check: Callable[[], None] - handoff: Optional[HostHandoff] = None + handoff: Optional[RecoveryHandoff] = None @runtime_checkable diff --git a/src/orchard/recovery_identity.py b/src/orchard/recovery_identity.py index 91c56bb0..c42028d8 100644 --- a/src/orchard/recovery_identity.py +++ b/src/orchard/recovery_identity.py @@ -1,4 +1,4 @@ -"""Bounded host USB identity parameters shared by payloads and RAM recovery.""" +"""Bounded boot identity parameters shared by payloads and RAM recovery.""" import base64 import binascii import json @@ -9,6 +9,8 @@ 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')) @@ -35,10 +37,10 @@ def object_json(raw): return result -def _selected(result, fields): +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'] != 1 or - result.get('source') != 'host-usb' or not isinstance(result.get('profile'), str) 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 @@ -47,7 +49,17 @@ def _selected(result, fields): def validate_policy(result): - _selected(result, POLICY_FIELDS) + 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) @@ -56,8 +68,16 @@ def validate_policy(result): 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') diff --git a/src/orchard/recovery_policy.py b/src/orchard/recovery_policy.py index 065cdc81..7d48fba2 100644 --- a/src/orchard/recovery_policy.py +++ b/src/orchard/recovery_policy.py @@ -5,7 +5,7 @@ def settings(board, hardware=None): - from .hardware import ROOT, device + from .hardware import ROOT, boot_recipe, device from .kernel import descriptor from .recovery_identity import validate_policy hardware = Path(hardware or ROOT) @@ -27,9 +27,16 @@ def settings(board, hardware=None): 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 = validate_policy({'schema': 1, 'source': 'host-usb', 'profile': selected['device']['profile'], + 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']}) + '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 2b52ddfb..e8412570 100644 --- a/src/orchard/transports/ssh.py +++ b/src/orchard/transports/ssh.py @@ -95,7 +95,8 @@ def check(): current = select_endpoint() if (not scope.matches(current) or (current['location'], current['serial_port']) != address or - (handoff is not None and current.get('handoff_id') != handoff.handoff_id)): + (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 @@ -146,6 +147,8 @@ def session(device: DeviceState, select_endpoint, *, expected_boot=None, handoff raise ValueError('Recovery identity differs from the selected device/boot') 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) 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-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/test_host_handoff.py b/tests/test_host_handoff.py index d5565195..0c21344f 100644 --- a/tests/test_host_handoff.py +++ b/tests/test_host_handoff.py @@ -10,7 +10,7 @@ from unittest.mock import Mock, patch from orchard.apple import cascadia -from orchard.contracts import Connection, HostHandoff +from orchard.contracts import Connection, RecoveryHandoff from orchard.state import BootState, DeviceState, private_directory from orchard.transports import ssh @@ -22,7 +22,7 @@ class HandoffChecks(unittest.TestCase): def setUp(self): - self.handoff = HostHandoff(NONCE, TEMPLATE, 0x8942, 10) + self.handoff = RecoveryHandoff(NONCE, TEMPLATE, 0x8942, 10) self.scope = BootState(DeviceState('p105', 123), BOOT) self.endpoint = {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, 'location': 'port', 'serial_port': '/dev/fake', 'handoff_id': NONCE} @@ -39,7 +39,7 @@ def test_identity_requires_nonce_template_provenance_and_exact_integer_ids(self) self.assertFalse(self.handoff.matches(missing)) for nonce in ('0' * 32, 'A' * 32, 'f' * 31): with self.subTest(nonce=nonce), self.assertRaises(ValueError): - HostHandoff(nonce, TEMPLATE, 0x8942, 10) + RecoveryHandoff(nonce, TEMPLATE, 0x8942, 10) def test_serial_bootstrap_checks_provenance_before_accepting_key(self): result = {**self.identity, 'host': '172.16.42.1', 'port': 22, @@ -65,6 +65,18 @@ def test_usb_nonce_change_stops_rpc_before_dispatch(self): Client(connection, Mock()).call('start') launch.assert_not_called() + def test_ios_origin_change_stops_rpc_even_with_the_same_boot_and_nonce(self): + ios = RecoveryHandoff(NONCE, TEMPLATE, 0x8942, 10, 'ios-device') + endpoint = {**self.endpoint, 'identity_source': 'ios-device'} + for source in (None, 'host-usb'): + check = ssh.endpoint_check(self.scope, endpoint, + Mock(return_value={**endpoint, 'identity_source': source}), ios) + from orchard.protocol import Client + with patch('orchard.protocol.subprocess.run') as launch: + with self.subTest(source=source), self.assertRaisesRegex(ValueError, 'changed'): + Client(Connection(['ssh'], self.scope, check, ios), Mock()).call('start') + launch.assert_not_called() + def test_authenticated_status_checks_template_before_yielding_connection(self): check = Mock() connection = Connection(['ssh'], self.scope, check, self.handoff) @@ -123,7 +135,7 @@ def setUp(self): env.start() self.addCleanup(env.stop) self.endpoint = {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, 'handoff_id': NONCE} - self.handoff = HostHandoff(NONCE, TEMPLATE, 0x8942, 10) + self.handoff = RecoveryHandoff(NONCE, TEMPLATE, 0x8942, 10) folder = private_directory(DeviceState('p105', 123).root / 'boot-attempts') self.path = folder / (NONCE + '.json') self.record = {'schema': 1, 'method': 'recovery-autogo', 'phase': 'complete', diff --git a/tests/test_host_identity_handoff.py b/tests/test_host_identity_handoff.py index 2a3e47c4..72a5c128 100644 --- a/tests/test_host_identity_handoff.py +++ b/tests/test_host_identity_handoff.py @@ -21,6 +21,8 @@ BOOT = '11111111-1111-4111-8111-111111111111' POLICY = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', 'compatibles': ['apple,p105ap'], 'chip': 0x8942, 'board': 0x0a} +DUAL_POLICY = {key: value for key, value in POLICY.items() if key != 'source'} +DUAL_POLICY.update(schema=2, sources=['host-usb', 'ios-device']) HANDOFF = {'schema': 1, 'source': 'host-usb', 'profile': 'p105', 'ecid': 123, 'chip': 0x8942, 'board': 0x0a, 'handoff_id': 'a'*32, 'template_sha256': 'b'*64} @@ -30,6 +32,23 @@ def raw_token(name, raw): class IdentityCodec(unittest.TestCase): + def test_exact_origin_allowlist_preserves_schema_one_host_policy(self): + self.assertTrue(codec.allows_source(POLICY, 'host-usb')) + self.assertFalse(codec.allows_source(POLICY, 'ios-device')) + for source in ('host-usb', 'ios-device'): + self.assertTrue(codec.allows_source(DUAL_POLICY, source)) + value = {**HANDOFF, 'source': source} + token = codec.encode_parameter(codec.HANDOFF_PARAMETER, value) + self.assertEqual(codec.parameters(token), {codec.HANDOFF_PARAMETER: value}) + for value in ([], ['host-usb']*2, ['ios-device', 'unknown'], [None], 'ios-device'): + with self.subTest(value=value), self.assertRaises(ValueError): + codec.validate_policy({**DUAL_POLICY, 'sources': value}) + for change in ({'source': 'ios-device'}, {'schema': 2}, {'sources': ['host-usb']}): + with self.subTest(change=change), self.assertRaises(ValueError): + codec.validate_policy({**POLICY, **change}) + self.assertEqual(codec.parameters(codec.encode_parameter(codec.POLICY_PARAMETER, DUAL_POLICY)), + {codec.POLICY_PARAMETER: DUAL_POLICY}) + def test_roundtrip_partial_and_complete_parameters(self): policy = codec.encode_parameter(codec.POLICY_PARAMETER, POLICY) handoff = codec.encode_parameter(codec.HANDOFF_PARAMETER, HANDOFF) @@ -139,7 +158,8 @@ def test_canonical_profile_live_board_attempt_and_private_cache(self): self.assertEqual(identity.cached(), record) self.assertEqual(stat.S_IMODE((identity.RUN/'identity.json').stat().st_mode), 0o600) serial = identity.serial(record) - self.assertTrue(serial.endswith(' BOARD:p105 HND:'+'a'*32)) + self.assertTrue(serial.endswith(' BOARD:p105 HND:'+'a'*32+' ORG:h')) + self.assertEqual(len(serial), 117) self.assertLessEqual(len(serial), 126) with self.assertRaisesRegex(ValueError, 'already published'): identity.initialize() @@ -153,6 +173,31 @@ def test_missing_handoff_or_policy_token_does_not_fall_back_to_adt(self): identity.initialize() self.assertFalse((identity.RUN/'identity.json').exists()) + def test_ios_device_origin_requires_staged_allowlist_and_stays_explicit(self): + ios_handoff = {**HANDOFF, 'source': 'ios-device'} + self.command_line(handoff=ios_handoff) + with self.assertRaisesRegex(ValueError, 'source is absent'): + identity.initialize() + identity.POLICY.write_text(json.dumps(DUAL_POLICY)) + self.command_line(policy=DUAL_POLICY, handoff=ios_handoff) + with mock.patch.object(identity, 'validate', side_effect=AssertionError('ADT fallback')): + record = identity.initialize() + self.assertEqual(record['identity_source'], 'ios-device') + self.assertTrue(identity.serial(record).endswith('HND:'+'a'*32+' ORG:i')) + self.assertEqual(identity.cached(), record) + # Changing the allowed origin in the live command line cannot reuse the cache. + self.command_line(policy=DUAL_POLICY, handoff=HANDOFF) + with self.assertRaisesRegex(ValueError, 'differs'): + identity.cached() + + def test_serial_rejects_missing_unknown_or_empty_origin(self): + record = {'profile': 'p105', 'ecid': 123, 'boot_id': BOOT, + 'identity_source': 'ios-device', 'handoff_id': 'a'*32, + 'template_sha256': 'b'*64, 'chip': 0x8942, 'board': 0x0a} + for source in (None, '', 'ios', 'configuration', []): + with self.subTest(source=source), self.assertRaises(ValueError): + identity.serial({**record, 'identity_source': source}) + def test_descriptor_policy_and_live_first_compatible_must_match(self): for raw in (b'apple,p105\0', b'apple,j1\0', b'apple,s5l8942x\0apple,p105ap\0', b'apple,p105ap', b'apple,p105ap\0\0'): diff --git a/tests/test_ios_attempt_files.py b/tests/test_ios_attempt_files.py new file mode 100644 index 00000000..a56e86fa --- /dev/null +++ b/tests/test_ios_attempt_files.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""The actual iOS attempt preparation stays within private, checked file copies.""" +import hashlib +import json +from pathlib import Path +import shutil +import struct +import subprocess +import sys +from tempfile import TemporaryDirectory +import unittest +import zlib + +from orchard import cascadia_payload as image, recovery_identity +from orchard.apple import ios_identity, ios_launcher +from test_ios_launch_identity import template + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'boot/ios-launcher/ios-start.c' +FIXTURE = ROOT / 'tests/fixtures/ios-attempt-files.c' + + +@unittest.skipUnless(sys.platform == 'darwin' and shutil.which('xcrun'), + 'Native iOS preparation checks need Apple CommonCrypto') +class IOSAttemptFiles(unittest.TestCase): + def test_actual_preparation_binds_and_checks_private_files_before_boot_apis(self): + source = SOURCE.read_text() + checked_at = source.index('static int checked(') + checked_end = source.index('\n#ifdef IOS_IDENTITY_SLOT_OFFSET', checked_at) + start = source.index('static void sha_text(') + stop = source.index('\n#endif\n', start) + actual = source[checked_at:checked_end] + '\n' + source[start:stop] + profile = ios_launcher.boot_profile('p105') + original, identity = ios_identity.prepare_template(template(), profile) + crc_offset = 24 + ibec = bytearray(b'\xa5' * 128) + struct.pack_into(' Date: Fri, 6 Nov 2026 21:43:30 -0600 Subject: [PATCH 16/16] boot: resend the bound configuration with the ARM32 trigger The loader validates its CB32 record at offset 0x7f00 before entering Linux. The short upload ended at 0x6001, leaving the record dependent on retention across the two transfers. Send the checked prefix through the record's final CRC byte at 0x7f44. This remains below the iBEC autogo threshold and ends with a short USB packet. Exercise identity binding that changes the DTB size, verify the uploaded configuration and its CRC, and reject stale or truncated prefixes. Keep receipt validation strict and document refusal of old compact-trigger receipts. The hardware startup failure remains under investigation. Assisted-by: LLM --- docs/boot-contract.md | 7 ++++--- docs/platforms-and-builds.md | 15 ++++++++++----- src/orchard/apple/cascadia.py | 4 ++-- tests/test_host_handoff.py | 7 +++++++ tests/test_recovery_autogo.py | 14 ++++++++++++-- 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/docs/boot-contract.md b/docs/boot-contract.md index cfe1d166..b6c55a4d 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -231,9 +231,10 @@ 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 its reserved code -area. The bound configuration stays in RAM from the first upload, and this small -transfer starts the complete bundle already in RAM. The transport rejects +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 diff --git a/docs/platforms-and-builds.md b/docs/platforms-and-builds.md index ba1943e6..321d1553 100644 --- a/docs/platforms-and-builds.md +++ b/docs/platforms-and-builds.md @@ -496,11 +496,16 @@ Project Cascadia's chain, as shaynelegault ran it on a P105: 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 - small second upload covers the loader code while retaining the bound - configuration from the full bundle, then makes iBEC enter the image already - stored at `0x80000000`. -4. **Staging bundle** at `0x80000000`: the `linux_boot` trampoline paints - status and turns MMU and caches off. Padding reaches `0x8000`, followed by + 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. diff --git a/src/orchard/apple/cascadia.py b/src/orchard/apple/cascadia.py index f41593c2..660bd36b 100644 --- a/src/orchard/apple/cascadia.py +++ b/src/orchard/apple/cascadia.py @@ -28,7 +28,7 @@ CONFIG = struct.Struct('<4s16I') AUTOGO_TRIGGER_LIMIT = 0x8000 LOADER_CODE_LIMIT = payload_image.LOADER_CODE_LIMIT -LOADER_TRIGGER_BYTES = LOADER_CODE_LIMIT + 1 +LOADER_TRIGGER_BYTES = CONFIG_AT + CONFIG.size RECOVERY_PACKET_BYTES = 512 RECOVERY_REARM_SECONDS = 1.0 @@ -104,7 +104,7 @@ def validate_bundle(data, selected): def loader_trigger(data, candidate=None): - """Return the exact small loader prefix that makes the patched iBEC run it.""" + """Resend the exact loader and bound configuration before iBEC enters it.""" size = LOADER_TRIGGER_BYTES trigger = data[:size] if candidate is None else candidate if (size >= AUTOGO_TRIGGER_LIMIT or any(data[LOADER_CODE_LIMIT:CONFIG_AT]) or diff --git a/tests/test_host_handoff.py b/tests/test_host_handoff.py index 0c21344f..9b6d84aa 100644 --- a/tests/test_host_handoff.py +++ b/tests/test_host_handoff.py @@ -150,10 +150,17 @@ def save(self): self.path.chmod(0o600) def test_completed_private_receipt_recovers_exact_handoff(self): + self.assertEqual(self.record['loader_bytes'], 0x7f44) self.save() self.assertEqual(cascadia.recovery_handoff('p105', self.endpoint), self.handoff) self.assertIsNone(cascadia.recovery_handoff('p105', {**self.endpoint, 'handoff_id': None})) + def test_previous_code_only_trigger_receipt_cannot_resume(self): + self.record['loader_bytes'] = 0x6001 + self.save() + with self.assertRaisesRegex(ValueError, 'handoff receipt'): + cascadia.recovery_handoff('p105', self.endpoint) + def test_unknown_incomplete_or_changed_receipt_cannot_resume(self): with self.assertRaisesRegex(ValueError, 'private completed'): cascadia.recovery_handoff('p105', self.endpoint) diff --git a/tests/test_recovery_autogo.py b/tests/test_recovery_autogo.py index 17a65902..dba70ec0 100644 --- a/tests/test_recovery_autogo.py +++ b/tests/test_recovery_autogo.py @@ -5,6 +5,7 @@ import struct from tempfile import TemporaryDirectory import unittest +import zlib from unittest.mock import Mock, patch from orchard.apple import cascadia, iboot32 @@ -130,8 +131,9 @@ def test_loader_trigger_is_the_exact_sub_packet_bundle_prefix(self): data = payload() trigger = cascadia.loader_trigger(data) self.assertEqual(trigger, data[:cascadia.LOADER_TRIGGER_BYTES]) - self.assertEqual(len(trigger), 0x6001) - self.assertLess(len(trigger), cascadia.CONFIG_AT) + self.assertEqual(len(trigger), 0x7f44) + self.assertEqual(len(trigger), cascadia.CONFIG_AT + cascadia.CONFIG.size) + self.assertNotEqual(len(trigger) % cascadia.RECOVERY_PACKET_BYTES, 0) self.assertLess(len(trigger), cascadia.AUTOGO_TRIGGER_LIMIT) self.assertNotEqual(data[cascadia.CONFIG_AT:cascadia.CONFIG_AT + 4], bytes(4)) for offset in (cascadia.LOADER_CODE_LIMIT, cascadia.CONFIG_AT - 1): @@ -515,6 +517,14 @@ def test_real_public_template_binds_two_uploads_without_changing_kernel_or_initr self.assertEqual(self.recovery.read_bytes(), self.data) original, bound = image.configuration(self.data), image.configuration(sent[0]) self.assertEqual(sent[1], cascadia.loader_trigger(sent[0])) + config = sent[1][cascadia.CONFIG_AT:] + self.assertEqual(config, sent[0][cascadia.CONFIG_AT:cascadia.CONFIG_AT + cascadia.CONFIG.size]) + self.assertNotEqual(config, self.data[cascadia.CONFIG_AT:cascadia.CONFIG_AT + cascadia.CONFIG.size]) + self.assertGreater(bound['dtb_size'], original['dtb_size']) + self.assertEqual(cascadia.CONFIG.unpack(config)[-1], zlib.crc32(config[:-4])) + for candidate in (sent[1][:-1], sent[1][:0x6001], cascadia.loader_trigger(self.data)): + with self.subTest(length=len(candidate)), self.assertRaisesRegex(ValueError, 'bundle prefix'): + cascadia.loader_trigger(sent[0], candidate) self.assertEqual(self.data[original['kernel_offset']:original['dtb_offset']], sent[0][bound['kernel_offset']:bound['dtb_offset']]) self.assertEqual(self.data[original['initrd_offset']:], sent[0][bound['initrd_offset']:])