diff --git a/boot/cascadia/NOTICE b/boot/cascadia/NOTICE new file mode 100644 index 00000000..a20b5572 --- /dev/null +++ b/boot/cascadia/NOTICE @@ -0,0 +1,14 @@ +The ARMv7 cache-maintenance and Linux handoff sequence is derived from the +Project Cascadia A5 staging loader: + + https://github.com/rzxvx/Project-Cascadia + reference revision: 091d441f19e101699f7e1835a80ccb48d6e54eec + license: GPL-2.0 + +The local P105 work fixed the executable entry by discarding ARM unwind +metadata and asserting the upload address in the linker script. + +This producer keeps those entry safeguards. Hardware addresses come from +the device's profile. Orchard's configuration record and DTB editor add +bounded external-initramfs placement; that extension requires a fresh +hardware boot before it is described as working. diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md new file mode 100644 index 00000000..e94076f0 --- /dev/null +++ b/boot/cascadia/README.md @@ -0,0 +1,43 @@ +# ARM32 iBEC staging payload + +`build --profile devices/p105/cascadia.toml --output DIR` builds a loader +with Orchard's kernel toolchain through `buildenv`. The output directory +contains `loader.bin`, its ELF and a source/profile/toolchain receipt. +The ELF check requires executable ARM code at the upload address and checks +every allocated section against the raw bytes. ARM unwind tables are +discarded so they cannot displace the entry instruction. + +`python -m orchard.image.cascadia --profile devices/p105/cascadia.toml +--loader DIR/loader.bin --loader-elf DIR/loader.elf --kernel zImage +--dtb s5l8942x-p105.dtb --initramfs recovery.cpio.gz --bootargs 'rdinit=/init' +--output linux.bin` assembles one device's payload. Kernel bytes remain +unchanged. The DTB editor preserves properties while updating `/chosen` +boot arguments and its initrd addresses, then reserves that destination. + +The layout is a checked loader followed by padding to 32 KiB, the zImage, +the edited DTB and the supplied initramfs, with each data address aligned to +eight bytes. The assembly entry disables the profile's watchdog before stack +setup; the C entry also clears it before validation. A rejected image remains +stopped for inspection. Validation checks the configuration checksum, bounds +and relocation alignment while touching only the uploaded low-memory image. +The loader then flushes caches and disables the inherited MMU/cache state +before copying the initramfs to reserved high RAM. Word copies handle the +aligned body and byte copies preserve the exact final tail. A barrier precedes +the Linux handoff with the DTB in `r2`. High RAM therefore needs no inherited +iBEC virtual mapping. +The fixed loader stack uses the gap below offset `0x7000`; code must finish +below `0x6000`, and the configuration starts at `0x7f00`. + +The zImage must carry the v6.12 ARM size table and end on an eight-byte +boundary. Its DTB follows immediately so the appended-DTB decompressor can +relocate it. Assembly bounds the inflated kernel together with the relocated +decompressor's workspace below the initramfs destination; unknown table +formats and overlapping layouts are refused. + +The receipt describes every byte range and its hash. It records +`architecture = "arm"` and `initramfs_encoding = "cascadia-dtb"`, allowing a +distribution to reject an image assembled with another architecture or +payload format. This external-initramfs extension awaits hardware testing; +the earlier P105 boot embedded its initramfs in the kernel. + +See `NOTICE` for source provenance. This producer contacts no device. diff --git a/boot/cascadia/build b/boot/cascadia/build new file mode 100755 index 00000000..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..952fc39e --- /dev/null +++ b/boot/cascadia/linux_boot.c @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* ARM32 iBEC handoff. See NOTICE for the Cascadia source and local changes. */ +typedef unsigned int u32; +typedef unsigned char u8; + +#include "cascadia-generated.h" + +struct config { + u8 magic[4]; + u32 version, header_size, load_address, kernel_offset; + u32 dtb_offset, dtb_size, initrd_offset, initrd_size; + u32 initrd_address, image_size, watchdog_address, l2_address; + u32 ram_start, ram_end, initrd_limit, crc32; +}; + +/* The assembler fills the dynamic fields and checksum before upload. */ +__attribute__((section(".loader_config"), used)) +volatile const struct config cascadia_config = { + { 'C', 'B', '3', '2' }, 1, sizeof(struct config), LOAD_ADDRESS, + KERNEL_OFFSET, 0, 0, 0, 0, INITRD_ADDRESS, 0, WATCHDOG_ADDRESS, + L2_ADDRESS, RAM_START, RAM_END, INITRD_LIMIT, 0, +}; + +static int range(u32 start, u32 size, u32 first, u32 end) +{ + return start >= first && start < end && size && size <= end - start; +} + +static u32 checksum(void) +{ + volatile const u8 *p = (volatile const u8 *)&cascadia_config; + u32 crc = ~0u, i, bit; + for (i = 0; i < sizeof(struct config) - sizeof(u32); i++) { + crc ^= p[i]; + for (bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1))); + } + return ~crc; +} + +static u32 big_endian(volatile const u8 *p) +{ + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | + ((u32)p[2] << 8) | p[3]; +} + +u32 cascadia_main(void) +{ + volatile const struct config *c = &cascadia_config; + volatile const u8 *dtb; + u32 i; + + if (WATCHDOG_ADDRESS) { + volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; + for (i = 0; i < 4; i++) + wdt[i] = 0; + } + if (c->magic[0] != 'C' || c->magic[1] != 'B' || + c->magic[2] != '3' || c->magic[3] != '2' || + c->version != 1 || c->header_size != sizeof(struct config) || + c->load_address != LOAD_ADDRESS || c->kernel_offset != KERNEL_OFFSET || + c->watchdog_address != WATCHDOG_ADDRESS || c->l2_address != L2_ADDRESS || + c->ram_start != RAM_START || c->ram_end != RAM_END || + c->initrd_limit != INITRD_LIMIT || + checksum() != c->crc32) + return 0; + if (!range(LOAD_ADDRESS, c->image_size, RAM_START, RAM_END) || + c->dtb_offset < KERNEL_OFFSET || c->dtb_size < 40 || + c->dtb_offset >= c->image_size || + c->dtb_size > c->image_size - c->dtb_offset || + c->initrd_offset < c->dtb_offset + c->dtb_size || + (c->initrd_offset & 3) || + c->initrd_offset >= c->image_size || + c->initrd_size > c->image_size - c->initrd_offset || + c->initrd_address != INITRD_ADDRESS || + !range(INITRD_ADDRESS, c->initrd_size, RAM_START, INITRD_LIMIT) || + INITRD_ADDRESS < LOAD_ADDRESS + c->image_size) + return 0; + dtb = (volatile const u8 *)(LOAD_ADDRESS + c->dtb_offset); + if (big_endian(dtb) != 0xd00dfeedu || big_endian(dtb + 4) != c->dtb_size) + return 0; + return LOAD_ADDRESS + c->dtb_offset; +} + +/* Called only after validation and MMU/cache teardown, using physical RAM. */ +void cascadia_relocate_initrd(u32 source, u32 target, u32 size) +{ + volatile const u32 *from = (volatile const u32 *)source; + volatile u32 *to = (volatile u32 *)target; + volatile const u8 *tail_from; + volatile u8 *tail_to; + + while (size >= sizeof(u32)) { + *to++ = *from++; + size -= sizeof(u32); + } + tail_from = (volatile const u8 *)from; + tail_to = (volatile u8 *)to; + while (size--) + *tail_to++ = *tail_from++; +} diff --git a/boot/cascadia/linux_boot_start.S b/boot/cascadia/linux_boot_start.S new file mode 100644 index 00000000..b2c10160 --- /dev/null +++ b/boot/cascadia/linux_boot_start.S @@ -0,0 +1,158 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Derived from Project Cascadia; see NOTICE. */ +#include "cascadia-generated.h" + + .arch armv7-a + .syntax unified + .arm + + .equ KERNEL_ENTRY, LOAD_ADDRESS + KERNEL_OFFSET + + .section .text.start, "ax" + .global _start + .global cascadia_main + .global linux_boot_jump +_start: + cpsid if + msr cpsr_c, #0xd3 + + /* Stop the profile's watchdog before depending on a stack or C entry. */ + ldr r0, =WATCHDOG_ADDRESS + cmp r0, #0 + movne r1, #0 + strne r1, [r0] + strne r1, [r0, #4] + strne r1, [r0, #8] + strne r1, [r0, #12] + dsb + ldr sp, =LOAD_ADDRESS + 0x7000 + bl cascadia_main + /* main validates the image and returns DTB physical address. */ + cmp r0, #0 + beq 1f + b linux_boot_jump +1: wfi + b 1b + +/* + * r0 = DTB physical address + * Tear down MMU/caches, then bx KERNEL_ENTRY with + * r0=0, r1=~0, r2=DTB + */ +linux_boot_jump: + mov r4, r0 /* save DTB */ + /* Capture the validated relocation before flushing inherited mappings. */ + ldr r3, =cascadia_config + ldr r5, [r3, #28] /* initrd_offset */ + ldr r6, [r3, #36] /* initrd_address */ + ldr r7, [r3, #32] /* initrd_size */ + ldr r0, =LOAD_ADDRESS + add r5, r5, r0 + + bl v7_flush_dcache_all + bl l2x0_flush_all + + mrc p15, 0, r0, c1, c0, 0 + bic r0, r0, #(1 << 0) /* MMU */ + bic r0, r0, #(1 << 2) /* D-cache */ + bic r0, r0, #(1 << 12) /* I-cache */ + bic r0, r0, #(1 << 11) /* Z */ + mcr p15, 0, r0, c1, c0, 0 + isb + + mov r0, #0 + mcr p15, 0, r0, c7, c5, 0 + mcr p15, 0, r0, c7, c5, 6 + mcr p15, 0, r0, c8, c7, 0 + dsb + isb + + /* iBEC need not identity-map the reserved high-RAM destination. */ + mov r0, r5 + mov r1, r6 + mov r2, r7 + bl cascadia_relocate_initrd + dsb + + mov r0, #0 + mvn r1, #0 + mov r2, r4 + ldr r3, =KERNEL_ENTRY + bx r3 + +1: wfi + b 1b + +v7_flush_dcache_all: + push {r4-r11, lr} + dmb + mrc p15, 1, r0, c0, c0, 1 + ands r3, r0, #0x7000000 + mov r3, r3, lsr #23 + beq .Lfinished + mov r10, #0 +.Lloop_level: + add r2, r10, r10, lsr #1 + mov r1, r0, lsr r2 + and r1, r1, #7 + cmp r1, #2 + blt .Lskip_level + mcr p15, 2, r10, c0, c0, 0 + isb + mrc p15, 1, r1, c0, c0, 0 + and r2, r1, #7 + add r2, r2, #4 + movw r4, #0x3ff + ands r4, r4, r1, lsr #3 + clz r5, r4 + movw r7, #0x7fff + ands r7, r7, r1, lsr #13 +.Lloop_way: + mov r9, r7 +.Lloop_set: + orr r11, r10, r4, lsl r5 + orr r11, r11, r9, lsl r2 + mcr p15, 0, r11, c7, c14, 2 + subs r9, r9, #1 + bge .Lloop_set + subs r4, r4, #1 + bge .Lloop_way +.Lskip_level: + add r10, r10, #2 + cmp r3, r10 + bgt .Lloop_level +.Lfinished: + mov r10, #0 + mcr p15, 2, r10, c0, c0, 0 + dsb + isb + pop {r4-r11, pc} + +l2x0_flush_all: + push {r0, r1, r2, lr} + ldr r0, =L2_ADDRESS + cmp r0, #0 + beq 2f + ldr r1, [r0] + lsr r2, r1, #6 + and r2, r2, #0x3f + cmp r2, #3 + cmpne r2, #2 + cmpne r2, #1 + bne 2f + ldr r1, [r0, #0x100] + tst r1, #1 + beq 2f + mov r1, #0xff00 + orr r1, r1, #0xff + str r1, [r0, #0x7fc] + ldr r2, =1000000 +1: ldr r1, [r0, #0x7fc] + tst r1, #1 + beq 2f + subs r2, r2, #1 + bne 1b +3: wfi + b 3b +2: dsb + pop {r0, r1, r2, pc} diff --git a/boot/initramfs/recovery/build-tools b/boot/initramfs/recovery/build-tools index a5ea574e..dd8a7fa5 100755 --- a/boot/initramfs/recovery/build-tools +++ b/boot/initramfs/recovery/build-tools @@ -1,12 +1,20 @@ #!/usr/bin/env bash -# Acquire pinned public inputs and build static AArch64 recovery tools. +# Acquire pinned public inputs and build static recovery tools for the selected ARM architecture. set -euo pipefail # Recipes need only Python 3.11+ and its standard library; Orchard passes its own. python=${ORCHARD_PYTHON:-python3} +arch=arm64 +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) [[ $# -ge 2 ]] || { echo '--arch needs arm or arm64' >&2; exit 1; }; arch=$2; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done +case "$arch" in arm|arm64) ;; *) echo '--arch needs arm or arm64' >&2; exit 1 ;; esac here=$(cd "$(dirname "$0")" && pwd) -work="${OMA_RECOVERY_TOOLS_WORK:-$here/.work/tools}" +work="${OMA_RECOVERY_TOOLS_WORK:-$here/.work/tools-$arch}" mkdir -p "$work" -"$python" - "$here/upstream.lock" "$work" <<'PY' +"$python" - "$here/upstream.lock" "$work" "$arch" <<'PY' import hashlib import json import pathlib @@ -23,7 +31,8 @@ for component, filename in (('busybox', 'busybox.deb'), ('iw', 'iw.tar.xz'), ('zstd', 'zstd.tar.gz'), ('e2fsprogs', 'e2fsprogs.tar.gz')): - item = lock[component] + key = 'busybox_arm' if component == 'busybox' and sys.argv[3] == 'arm' else component + item = lock[key] target = work / filename if target.exists(): source = target.open('rb') @@ -49,9 +58,11 @@ for component, filename in (('busybox', 'busybox.deb'), pathlib.Path(temporary.name).rename(target) print(f'Verified {component}: {target}') PY -"$python" "$here/../../../src/orchard/buildenv.py" build --arch arm64 --file "$here/Containerfile" \ - --image localhost/orchard-hardware-recovery-tools:bookworm --context "$here" -"$python" "$here/../../../src/orchard/buildenv.py" run --arch arm64 \ - --bind "$work:/work:rw" --bind "$here:/recipe:ro" \ - --image localhost/orchard-hardware-recovery-tools:bookworm -- bash /recipe/build-tools-in-container +image=localhost/orchard-hardware-recovery-tools:bookworm +[[ "$arch" == arm64 ]] || image=localhost/orchard-hardware-recovery-tools-arm:bookworm +"$python" "$here/../../../src/orchard/buildenv.py" build --arch "$arch" --file "$here/Containerfile" \ + --image "$image" --context "$here" +"$python" "$here/../../../src/orchard/buildenv.py" run --arch "$arch" \ + --bind "$work:/work:rw" --bind "$here:/recipe:ro" --env "ORCHARD_RECOVERY_ARCH=$arch" \ + --image "$image" -- bash /recipe/build-tools-in-container echo "Recovery tools: $work/bin" diff --git a/boot/initramfs/recovery/build-tools-in-container b/boot/initramfs/recovery/build-tools-in-container index d6b04b48..8186df88 100755 --- a/boot/initramfs/recovery/build-tools-in-container +++ b/boot/initramfs/recovery/build-tools-in-container @@ -1,6 +1,9 @@ #!/bin/bash set -euo pipefail work=${ORCHARD_WORK:-/work} +recipe=${ORCHARD_RECIPE:-/recipe} +arch=${ORCHARD_RECOVERY_ARCH:-arm64} +case "$arch" in arm) deb_arch=armhf ;; arm64) deb_arch=arm64 ;; *) echo 'Unsupported recovery architecture' >&2; exit 1 ;; esac [[ ! -e "$work/bin" ]] || { echo 'Tool output exists; choose a fresh work directory' >&2; exit 1; } build=$(mktemp -d "$work/build.XXXXXX") mkdir "$build/bin" @@ -67,7 +70,7 @@ tar -xJf "$work/iw.tar.xz" -C "$build" tar -xzf "$work/zstd.tar.gz" -C "$build" make -C "$build/zstd-1.5.7/lib" -j2 libzstd.a CC=musl-gcc ZSTD_LEGACY_SUPPORT=0 $CC $CFLAGS -static -Wall -Wextra -pthread -I"$build/zstd-1.5.7/lib" -o "$build/bin/orchard-write" \ - "${ORCHARD_RECIPE:-/recipe}/orchard-write.c" "$build/zstd-1.5.7/lib/libzstd.a" + "$recipe/orchard-write.c" "$build/zstd-1.5.7/lib/libzstd.a" tar -xzf "$work/e2fsprogs.tar.gz" -C "$build" ( cd "$build/e2fsprogs-1.47.4" @@ -78,11 +81,13 @@ tar -xzf "$work/e2fsprogs.tar.gz" -C "$build" cp resize/resize2fs misc/tune2fs "$build/bin/" ) +[[ $(dpkg-deb -f "$work/busybox.deb" Architecture) == "$deb_arch" ]] || { + echo 'BusyBox package architecture differs from the target' >&2; exit 1; +} + dpkg-deb -x "$work/busybox.deb" "$build/package" cp "$build/package/bin/busybox" "$build/bin/busybox" chmod 755 "$build/bin/"* -for tool in "$build/bin/"*; do - file "$tool" | grep -q 'statically linked' || { echo "$tool is not static" >&2; exit 1; } -done +python3 "$recipe/check-tools.py" --arch "$arch" "$build/bin/"* mv "$build/bin" "$work/bin" echo "Static recovery tools copied to $work/bin" diff --git a/boot/initramfs/recovery/check-tools.py b/boot/initramfs/recovery/check-tools.py new file mode 100644 index 00000000..5ff6eb61 --- /dev/null +++ b/boot/initramfs/recovery/check-tools.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Check the recovery tools' target ISA and static ELF linkage without running them.""" +import argparse +from pathlib import Path +import stat +import struct + + +TARGETS = {'arm': (1, 40), 'arm64': (2, 183)} + + +def validate(data, arch): + if arch not in TARGETS: + raise ValueError('Unsupported recovery architecture: ' + arch) + elf_class, machine = TARGETS[arch] + if len(data) < 16 or data[:7] != b'\x7fELF' + bytes((elf_class, 1, 1)): + raise ValueError('ELF class or byte order differs from target ' + arch) + header = '<16sHHIIIIIHHHHHH' if elf_class == 1 else '<16sHHIQQQIHHHHHH' + if len(data) < struct.calcsize(header): + raise ValueError('Incomplete ELF header') + fields = struct.unpack_from(header, data) + if fields[1] not in (2, 3) or fields[2] != machine or fields[3] != 1: + raise ValueError('ELF machine or executable type differs from target ' + arch) + if fields[8] != struct.calcsize(header): + raise ValueError('Invalid ELF header size') + if arch == 'arm' and (fields[7] & 0xff000000 != 0x05000000 or + fields[7] & 0x600 != 0x400): + raise ValueError('ARM recovery requires EABI5 hard-float executables') + segment = ' len(data): + raise ValueError('Invalid ELF program header table') + dynamic = [] + for index in range(count): + values = struct.unpack_from(segment, data, offset + index * stride) + kind = values[0] + start, size = (values[1], values[4]) if elf_class == 1 else (values[2], values[5]) + if start + size > len(data): + raise ValueError('ELF segment exceeds the file') + if kind == 3: + raise ValueError('Recovery tools must be static: PT_INTERP is present') + if kind == 2: + dynamic.append((start, size)) + entry = ' 64 * 1024 * 1024: + raise ValueError('Expected a bounded regular executable: ' + str(path)) + validate(path.read_bytes(), arch) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--arch', choices=TARGETS, required=True) + parser.add_argument('tools', nargs='+', type=Path) + args = parser.parse_args() + for path in args.tools: + try: + check(path, args.arch) + except (OSError, ValueError, struct.error) as error: + parser.exit(1, str(path) + ': ' + str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/boot/initramfs/recovery/init b/boot/initramfs/recovery/init index 4b6fd2fa..e8802263 100755 --- a/boot/initramfs/recovery/init +++ b/boot/initramfs/recovery/init @@ -17,19 +17,30 @@ case "$board" in apple,*) ;; *) echo "Not an Apple device for Orchard recovery: $board"; exec sh ;; esac -/sbin/recovery-usb -ip address add 172.16.42.1/30 dev usb0 -ip link set usb0 up -udhcpd -f /etc/udhcpd.conf & -echo "${board#apple,} recovery: USB serial ready; provision an SSH key before SSH starts." -# Respawn unconditionally: a USB re-enumeration hangs up the shell, which then -# exits non-zero, and serial is the only way to provision SSH. +# Serial survives networking failures and repeated USB enumeration. ( while true; do getty -n -l /bin/sh ttyGS0 115200 vt100; sleep 1; done ) & -# Internal storage comes up read-only in the background (storage-fence); the -# host installs over SSH once /run/orchard/storage-ready exists. -/usr/libexec/orchard/storage-fence > /run/orchard/storage.log 2>&1 & +if ! /sbin/recovery-usb > /run/orchard/usb.log 2>&1; then + echo 'Recovery USB setup failed; inspect /run/orchard/usb.log.' + while :; do sleep 60; done +fi +if ip address add 172.16.42.1/30 dev usb0 && ip link set usb0 up; then + udhcpd -f /etc/udhcpd.conf & +else + echo 'Recovery networking failed; USB serial remains available.' +fi +echo "${board#apple,} recovery: USB serial ready; provision an SSH key before SSH starts." +if storage=$(/usr/bin/python3 /usr/libexec/orchard/policy.py storage); then + if [ "$storage" = automatic ]; then + /usr/libexec/orchard/storage-fence > /run/orchard/storage.log 2>&1 & + else + echo manual > /run/orchard/storage-manual + echo 'Storage waits for a manual request after serial and authenticated SSH are ready.' + fi +else + echo 'Recovery storage policy failed; storage remains unloaded.' +fi # After an install, `orchard-device start` asks for the new root: if its boot # entry boots this same kernel, PID 1 switches into it, so the first start # needs no second DFU boot. Later starts go through the entry (orchard launch). diff --git a/boot/initramfs/recovery/install-toolchain b/boot/initramfs/recovery/install-toolchain index e0375165..6c33aaad 100644 --- a/boot/initramfs/recovery/install-toolchain +++ b/boot/initramfs/recovery/install-toolchain @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config xz-utils) +packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config python3 xz-utils) if [[ ${1:-} == --packages ]]; then printf '%s\n' "${packages[@]}"; exit 0; fi apt-get update apt-get install -y --no-install-recommends "${packages[@]}" diff --git a/boot/initramfs/recovery/upstream.lock b/boot/initramfs/recovery/upstream.lock index c0cd9e62..0a8e32a1 100644 --- a/boot/initramfs/recovery/upstream.lock +++ b/boot/initramfs/recovery/upstream.lock @@ -26,5 +26,9 @@ "e2fsprogs": { "url": "https://mirrors.edge.kernel.org/pub/linux/kernel/people/tytso/e2fsprogs/v1.47.4/e2fsprogs-1.47.4.tar.gz", "sha256": "da274408bebbfd13a5a2fc3cfc66e3ffff17c48534673aa67f88d49b99123b96" + }, + "busybox_arm": { + "url": "https://deb.debian.org/debian/pool/main/b/busybox/busybox-static_1.35.0-4+deb12u1+b1_armhf.deb", + "sha256": "2f68dbeaebeda49f3cde34ac421d5add85fb1c837ff7c4a4971945340d9d66ff" } } diff --git a/boot/initramfs/recovery/usb-gadget b/boot/initramfs/recovery/usb-gadget index e5b7a826..4356b57a 100755 --- a/boot/initramfs/recovery/usb-gadget +++ b/boot/initramfs/recovery/usb-gadget @@ -1,6 +1,9 @@ #!/bin/sh # USB NCM networking and ACM serial, restricted to the attached host. set -eu +network=$(/usr/bin/python3 /usr/libexec/orchard/policy.py network) +modules=$(/usr/bin/python3 /usr/libexec/orchard/policy.py modules) +for module in $modules; do modprobe "$module"; done mkdir -p /sys/kernel/config mount -t configfs -o nodev,noexec,nosuid configfs /sys/kernel/config g=/sys/kernel/config/usb_gadget/linux-recovery @@ -16,8 +19,8 @@ echo 'Orchard Recovery' > "$g/strings/0x409/product" printf '%s\n' "$identity" > "$g/strings/0x409/serialnumber" echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" echo 500 > "$g/configs/c.1/MaxPower" -mkdir "$g/functions/ncm.usb0" "$g/functions/acm.usb0" -ln -s "$g/functions/ncm.usb0" "$g/configs/c.1/ncm.usb0" +mkdir "$g/functions/$network.usb0" "$g/functions/acm.usb0" +ln -s "$g/functions/$network.usb0" "$g/configs/c.1/$network.usb0" ln -s "$g/functions/acm.usb0" "$g/configs/c.1/acm.usb0" set -- /sys/class/udc/* [ "$#" -eq 1 ] && [ -e "$1" ] diff --git a/chips/s5l8942x.toml b/chips/s5l8942x.toml index 50ba0c2d..32a4fc52 100644 --- a/chips/s5l8942x.toml +++ b/chips/s5l8942x.toml @@ -14,3 +14,7 @@ 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" + +# Public Cascadia RAM recovery correlates a guarded host USB identity with the live FDT and boot ID. +# Linux SoC ECID access and on-device public recovery identity remain unqualified. diff --git a/devices/p105/README.md b/devices/p105/README.md index a39986c9..f66904bd 100644 --- a/devices/p105/README.md +++ b/devices/p105/README.md @@ -26,3 +26,14 @@ the IPSW members `058-24036-023.dmg`, `kernelcache.release.p105`, `Firmware/dfu/ The touch calibration is per unit and private: `lib/firmware/mtcal.bin`, the unit's syscfg `MtCl`, captured from its jailbroken iOS. It is never part of a public recipe or release. + +## ARMv7 recovery assembly + +The platform's `cascadia` payload producer builds the staging loader from +source with `cascadia.toml`. Select this board explicitly when resolving a +release's inputs: `python -m orchard.inputs --device p105 --output inputs.json`. +The profile identifies the memory window and handoff registers for this board. +The generated loader checks its configuration before copying the recovery +initramfs into the reserved high-memory range. The DTB names that initramfs. +This extension needs a hardware boot of the final producer revision; the +earlier installed P105 boot used an initramfs compiled into its kernel. diff --git a/devices/p105/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..95a55378 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -13,6 +13,8 @@ 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"] # The CLI consumes an owner-private boot manifest. Public launch assets await release integration. 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/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..eb21cb70 100644 --- a/platforms/apple-armv7.toml +++ b/platforms/apple-armv7.toml @@ -15,3 +15,17 @@ config = "kernel/configs/apple-armv7/config" defconfig = "orchard_applearmv7_defconfig" # the same config in OrchardLinux/linux branch = "armv7" # its branch in OrchardLinux/linux loader = "cascadia" # the payload format that boots this kernel + +[root] +# H2FMI is built in. The installed NAND backend supplies the early root +# setup; the arm64 platforms' NVMe modules do not apply to this platform. +storage_modules = [] + +[recovery] +# The guarded host transport binds a fresh USB identity to each RAM boot. +identity_source = "host-usb" +usb_network = "ecm" +usb_modules = ["libcomposite", "usb_f_ecm", "usb_f_acm"] +manual_modules = ["apple_sftl"] +# Mapping recovery begins only after serial and authenticated SSH are ready. +storage_activation = "manual" diff --git a/runtime/recovery/device.py b/runtime/recovery/device.py index bb217220..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/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/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/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/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..d3c899de --- /dev/null +++ b/src/orchard/image/cascadia.py @@ -0,0 +1,490 @@ +#!/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 + +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 __name__ == '__main__': + main() diff --git a/src/orchard/image/elf.py b/src/orchard/image/elf.py index 48c6fc14..914421f3 100644 --- a/src/orchard/image/elf.py +++ b/src/orchard/image/elf.py @@ -1,35 +1,100 @@ -"""Read ELF64 little-endian DT_NEEDED names without executing target binaries.""" +"""Inspect little-endian ELF32/ELF64 dependencies without executing target code.""" import struct +def _parts(data): + if data[:4] != b'\x7fELF': + return None + if len(data) < 16 or data[5:7] != b'\x01\x01' or data[4] not in (1, 2): + raise ValueError('Unsupported ELF encoding') + width = data[4] + header_size, program_size = (52, 32) if width == 1 else (64, 56) + if len(data) < header_size: + raise ValueError('Incomplete ELF header') + machine = struct.unpack_from(' len(data)): + raise ValueError('ELF program headers exceed the file') + segments = [] + for index in range(count): + position = offset + size * index + if width == 1: + kind, file_offset, virtual, _, file_size, _, _, _ = struct.unpack_from('<8I', data, position) + else: + kind, _, file_offset, virtual, _, file_size, _, _ = struct.unpack_from(' len(data): + raise ValueError('ELF segment exceeds the file') + segments.append((kind, virtual, file_offset, file_size)) + return width, machine, segments + + +def architecture(data): + """Return Orchard's architecture for an ARM executable or shared object.""" + parsed = _parts(data) + if parsed is None: + raise ValueError('Expected an ELF executable') + width, machine, _ = parsed + if width == 2 and machine == 183: + return 'arm64' + if width == 1 and machine == 40: + flags = struct.unpack_from('> 24 != 5 or not flags & 0x400 or flags & 0x200: + raise ValueError('ARM recovery requires EABI5 hard-float executables') + return 'arm' + raise ValueError('Unsupported recovery ELF architecture') + + def needed(data): - if data[:6] != b'\x7fELF\x02\x01': + parsed = _parts(data) + if parsed is None: return set() - offset = struct.unpack_from(' length: + raise ValueError('ELF string table exceeds its load segment') + length = string_size + result = set() + for name in names: + if name >= length: + raise ValueError('ELF dependency offset exceeds the string table') + end = data.find(b'\0', start + name, start + length) + if end < 0: + raise ValueError('Unterminated ELF dependency name') + try: + text = data[start + name:end].decode('ascii') + except UnicodeDecodeError as error: + raise ValueError('Non-ASCII ELF dependency name') from error + if not text or '/' in text or text in ('.', '..'): + raise ValueError('Invalid ELF dependency name') + result.add(text) + return result raise ValueError('ELF string table is outside load segments') diff --git a/src/orchard/image/platform.py b/src/orchard/image/platform.py index 76a5e4fb..f3a1ec7c 100644 --- a/src/orchard/image/platform.py +++ b/src/orchard/image/platform.py @@ -17,6 +17,8 @@ import gzip import io import json +import re +import tomllib from pathlib import Path import stat import tarfile @@ -49,7 +51,7 @@ def boards(platform, hardware=None): ROOT_ARCH = 'arm64' # distributions build one arm64 root for every platform -def platforms(hardware=None): +def platforms(hardware=None, architecture=ROOT_ARCH): """Every platform with at least one described board whose kernel runs the shared arm64 root, sorted. A platform of another architecture (apple-armv7) has no platform layer until a root of its architecture exists.""" @@ -57,7 +59,7 @@ def platforms(hardware=None): from ..ram import boards_by_platform hardware = hardware or ROOT return sorted(platform for platform, boards in boards_by_platform(hardware).items() - if descriptor(hardware, sorted(boards)[0])['arch'] == ROOT_ARCH) + if architecture is None or descriptor(hardware, sorted(boards)[0])['arch'] == architecture) def kernel(platform, *, hardware=None, cache=None): @@ -79,7 +81,7 @@ def tools(hardware=None, cache=None, platform='apple-16k'): return prepare_tools(hardware, cache, device=sorted(boards(platform, hardware))[0]) / 'busybox' -def initrd(modules, release, busybox, *, hardware=None): +def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_MODULES): """orchard's initrd for installed systems: BusyBox, the storage modules and their dependencies. MODULES is {path: (data, mode)} as module_files gives it. @@ -91,9 +93,9 @@ def initrd(modules, release, busybox, *, hardware=None): name, _, needs = line.partition(':') depends[name] = needs.split() by_name = {Path(name).name.split('.ko')[0].replace('_', '-'): name for name in depends} - loaded = [name for name in STORAGE_MODULES if name.replace('_', '-') in by_name] - if not loaded: - raise ValueError(f'Kernel bundle has no storage module: {", ".join(STORAGE_MODULES)}') + loaded = [name for name in storage_modules if name.replace('_', '-') in by_name] + if storage_modules and not loaded: + raise ValueError(f'Kernel bundle has no storage module: {", ".join(storage_modules)}') wanted, pending = [], [by_name[name.replace('_', '-')] for name in loaded] while pending: name = pending.pop() @@ -121,14 +123,31 @@ def initrd(modules, release, busybox, *, hardware=None): return output.read_bytes() +def storage_modules(hardware, platform): + """The platform's early-root modules; an empty list denotes built-in storage.""" + if not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', platform): + raise ValueError('Invalid platform name') + settings = tomllib.loads((Path(hardware) / 'platforms' / (platform + '.toml')).read_text()) + modules = settings.get('root', {}).get('storage_modules', list(STORAGE_MODULES)) + if (not isinstance(modules, list) or + any(not isinstance(name, str) or not re.fullmatch(r'[a-zA-Z0-9_+-]+', name) for name in modules) or + len(modules) != len(set(modules))): + raise ValueError('Platform root.storage_modules must be distinct kernel module names') + return tuple(modules) + + def files(bundle, platform, board_dtbs, *, busybox, hardware=None): """(kernel release, {path: (data, mode)}) of PLATFORM's layer from a staged kernel BUNDLE.""" hardware = Path(hardware or ROOT) + from ..kernel import descriptor + described = descriptor(hardware, sorted(board_dtbs)[0]) bundle = Path(bundle) release, result = module_files(bundle / 'lib/modules') base = f'usr/lib/modules/{release}/' - result[base + 'vmlinuz'] = ((bundle / 'Image.gz').read_bytes(), 0o644) - result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware), 0o644) + result[base + 'vmlinuz'] = ((bundle / described['image']).read_bytes(), 0o644) + modules_to_load = storage_modules(hardware, platform) + result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware, + storage_modules=modules_to_load), 0o644) for dtb in sorted(set(board_dtbs.values())): path = bundle / 'dtbs' / (dtb + '.dtb') if path.is_symlink() or not path.is_file(): @@ -145,7 +164,8 @@ def files(bundle, platform, board_dtbs, *, busybox, hardware=None): raise ValueError(f'System defaults belong under /usr/lib or /usr/share/alsa/ucm2: {name}') result[name] = (path.read_bytes(), 0o755 if path.stat().st_mode & 0o111 else 0o644) description = {'schema': SCHEMA, 'platform': platform, 'kernel_release': release, - 'boards': dict(sorted(board_dtbs.items()))} + 'boards': dict(sorted(board_dtbs.items())), 'architecture': described['arch'], + 'image': described['image']} result['usr/lib/orchard/platform.json'] = ((json.dumps(description, indent=2) + '\n').encode(), 0o644) result['usr/lib/orchard/platform.SHA256SUMS'] = (checksums(result), 0o644) return release, result @@ -166,21 +186,36 @@ def write(layer, output): return expanded, len(layer) -def build(platform, output_directory, *, hardware=None, cache=None, bundle=None, busybox=None): +def build(platform, output_directory, *, hardware=None, cache=None, bundle=None, busybox=None, + root_architecture=None, device=None): """PLATFORM's layer as OUTPUT_DIRECTORY/platform-PLATFORM.tar.gz, plus its receipt. BUNDLE is a staged kernel bundle and BUSYBOX orchard's static BusyBox; without them they are built or reused from the cache. """ board_dtbs = boards(platform, hardware) + if device is not None: + if device not in board_dtbs: + raise ValueError(f'Device {device} does not belong to platform {platform}') + board_dtbs = {device: board_dtbs[device]} + if root_architecture is not None: + from ..kernel import descriptor + architecture = descriptor(hardware or ROOT, sorted(board_dtbs)[0])['arch'] + if architecture != root_architecture: + raise ValueError(f'Platform {platform} architecture {architecture} differs from root {root_architecture}') bundle = Path(bundle) if bundle is not None else kernel(platform, hardware=hardware, cache=cache) busybox = Path(busybox) if busybox is not None else tools(hardware, cache, platform) + if root_architecture is not None: + from .elf import architecture as elf_architecture + if elf_architecture(busybox.read_bytes()) != root_architecture: + raise ValueError('Platform initrd BusyBox differs from the root architecture') release, layer = files(bundle, platform, board_dtbs, busybox=busybox, hardware=hardware) output = Path(output_directory) / f'platform-{platform}.tar.gz' expanded, members = write(layer, output) return {'file': output.name, 'bytes': output.stat().st_size, 'sha256': sha(output), 'compression': 'gzip', 'expanded_bytes': expanded, 'members': members, - 'kernel_release': release, 'boards': sorted(board_dtbs)} + 'kernel_release': release, 'boards': sorted(board_dtbs), + 'architecture': json.loads(layer['usr/lib/orchard/platform.json'][0])['architecture']} def main(): @@ -190,9 +225,13 @@ def main(): parser.add_argument('--kernel-dir', type=Path, help='a staged kernel bundle (default: build or reuse it)') parser.add_argument('--busybox', type=Path, help="orchard's static BusyBox (default: build or reuse it)") parser.add_argument('--receipt', type=Path, help="also write the layer's receipt (JSON) here") + parser.add_argument('--device', help='restrict the platform layer to this board') + parser.add_argument('--root-architecture', choices=('arm', 'arm64'), + help='require the platform to match this root architecture') args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) - receipt = build(args.platform, args.output, bundle=args.kernel_dir, busybox=args.busybox) + receipt = build(args.platform, args.output, bundle=args.kernel_dir, busybox=args.busybox, + root_architecture=args.root_architecture, device=args.device) if args.receipt: args.receipt.write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') print(json.dumps(receipt, indent=2)) diff --git a/src/orchard/image/prepare_runtime.py b/src/orchard/image/prepare_runtime.py index 9317506a..5af96d98 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('./') @@ -34,7 +41,10 @@ def stage_runtime(base, receipt, root): selected = (tool or name == 'etc/mke2fs.conf' or re.fullmatch(r'usr/bin/python3(?:\.[0-9]+)?', name) or re.match(r'usr/lib/python3\.[0-9]+(?:/|$)', name) or libraries.fullmatch(name)) - if (not selected or any('/' + component + '/' in '/' + name + '/' for component in + # Python's config directory contains build support, including + # relocatable objects that do not belong in the recovery runtime. + if (not selected or re.match(r'usr/lib/python3\.[0-9]+/config-[^/]+(?:/|$)', name) or + any('/' + component + '/' in '/' + name + '/' for component in ('site-packages', 'test', 'tests', '__pycache__', 'idlelib', 'tkinter')) or ('/lib-dynload/' in name and Path(name).name.startswith( ('_tkinter', '_curses', '_test', '_ctypes_test', '_dbm', '_gdbm', '_sqlite3', 'readline')))): @@ -74,7 +84,10 @@ def stage_runtime(base, receipt, root): required = set() for path in (root / 'usr').rglob('*'): if path.is_file() and not path.is_symlink(): - required.update(needed(path.read_bytes())) + data = path.read_bytes() + if data.startswith(b'\x7fELF') and elf_architecture(data) != architecture: + raise ValueError('Recovery contains an executable for another architecture: ' + str(path)) + required.update(needed(data)) missing = set() for name in required: path = root / 'usr/lib' / name @@ -112,12 +125,38 @@ def stage_runtime(base, receipt, root): raise ValueError('Python runtime dependency closure did not converge') +def check_recovery_modules(root, release, recovery): + """Manual storage must stay modular; declared USB dependencies must exist.""" + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(recovery['runtime']['usb_modules']) & normalized(recovery['runtime']['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + directory = root / 'usr/lib/modules' / release + builtins = {Path(name).stem.replace('-', '_') for name in + (directory / 'modules.builtin').read_text().splitlines() if name} + available = {path.stem.replace('-', '_') for path in directory.rglob('*.ko')} + for name in recovery['runtime']['manual_modules']: + if name.replace('-', '_') in builtins: + raise ValueError('Manual recovery module is built in: ' + name) + for name in recovery['runtime']['usb_modules']: + if name.replace('-', '_') not in builtins | available: + raise ValueError('Recovery USB module is unavailable: ' + name) + + def main(): parser = argparse.ArgumentParser(description=__doc__) for name in ('busybox', 'dropbear', 'dropbearkey', 'wpa-supplicant', 'wpa-cli', 'iw', 'base', 'base-receipt', 'modules-dir', 'root-receipt', 'output'): parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--architecture', choices=('arm', 'arm64'), default='arm64') + parser.add_argument('--device', help='stage the selected board recovery policy') args = parser.parse_args() + recovery = None + if args.device: + from ..recovery_policy import settings + from ..kernel import descriptor + if descriptor(PROJECT, args.device)['arch'] != args.architecture: + parser.error('Recovery board architecture differs from the runtime') + recovery = settings(args.device) root = args.output if root.exists(): parser.error('Choose a new staging directory') @@ -129,13 +168,18 @@ def main(): (args.wpa_supplicant, 'usr/sbin/wpa_supplicant'), (args.wpa_cli, 'usr/bin/wpa_cli'), (args.iw, 'usr/sbin/iw')): copy(source, root / name, 0o755) - stage_runtime(args.base, args.base_receipt, root) + for source in (args.busybox, args.dropbear, args.dropbearkey, args.wpa_supplicant, args.wpa_cli, args.iw): + if elf_architecture(source.read_bytes()) != args.architecture: + raise ValueError('Recovery tool architecture differs: ' + str(source)) + stage_runtime(args.base, args.base_receipt, root, args.architecture) kernel_release, public = runtime_files(args.modules_dir) for name, (data, mode) in {**public, **recovery_files()}.items(): path = root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) path.chmod(mode) + if recovery is not None: + check_recovery_modules(root, kernel_release, recovery) # The release this recovery belongs to; `orchard-device status` reports it. # Root images carry no kernel: the release's platform layer of the same # kernel bundle does, so the recovery and an installed root match. @@ -151,6 +195,17 @@ def main(): (root / 'etc/group').write_text('root:x:0:\n') (root / 'etc/shadow').write_text('root:!:0:0:99999:7:::\n') (root / 'etc/shadow').chmod(0o600) + if recovery is not None: + for name, value in (('recovery-identity.json', recovery['identity']), ('recovery.json', recovery['runtime'])): + path = root / 'etc/orchard' / name + path.write_text(json.dumps(value, sort_keys=True) + '\n') + path.chmod(0o644) + manual = recovery['runtime']['manual_modules'] + if manual: + blacklist = root / 'etc/modprobe.d/orchard-recovery.conf' + blacklist.parent.mkdir(parents=True, exist_ok=True) + blacklist.write_text(''.join('blacklist ' + name + '\n' for name in manual)) + blacklist.chmod(0o644) print('Public recovery staged:', root) diff --git a/src/orchard/image/recovery.py b/src/orchard/image/recovery.py index 7c2ee440..70d84a01 100644 --- a/src/orchard/image/recovery.py +++ b/src/orchard/image/recovery.py @@ -19,10 +19,40 @@ def main(): parser.add_argument('--dtb', type=Path, action='append', required=True, help='the DTB of each device this recovery boots; repeat') parser.add_argument('--logo', type=Path, help="the distribution's m1n1 logo payload (see assemble.py)") + parser.add_argument('--platform', default='apple-16k', help='kernel platform and payload format') + parser.add_argument('--device', help='the board whose Cascadia handoff profile to use') diagnostics = parser.add_mutually_exclusive_group() diagnostics.add_argument('--verbose', action='store_true', help='enable visible Linux framebuffer console and kernel diagnostics') args = parser.parse_args() + from ..kernel import descriptor + from .platform import boards + described = descriptor(ROOT, args.platform) + profile = None + selected = args.device + if selected: + from ..hardware import device as described_device + if described_device(selected)['kernel']['platform'] != described['platform']: + parser.error('Recovery board does not belong to the selected platform') + if described['loader'] == 'cascadia': + if len(args.dtb) != 1 or args.logo is not None: + parser.error('Cascadia recovery takes one board DTB and no m1n1 logo') + available = boards(described['platform']) + selected = args.device + if selected is None: + candidates = [board for board, dtb in available.items() if args.dtb[0].stem == dtb] + if len(candidates) != 1: + parser.error('Select the Cascadia board with --device') + selected = candidates[0] + if selected not in available or args.dtb[0].stem != available[selected]: + parser.error('The Cascadia board and supplied DTB differ') + from ..hardware import payload_profile, device as described_device + from .cascadia import check_compatible + try: + profile = payload_profile(selected, 'cascadia', ROOT) + check_compatible(args.dtb[0].read_bytes(), described_device(selected, ROOT)['device']['dt_compatible']) + except ValueError as error: + parser.error(str(error)) manifest = 'boot-verbose.json' if args.verbose else 'boot.json' if args.output.exists(): parser.error('choose a new output path') @@ -39,11 +69,17 @@ def main(): '--wpa-cli', str(args.wpa_cli), '--iw', str(args.iw), '--base', str(args.base), '--base-receipt', str(args.base_receipt), '--modules-dir', str(args.modules_dir), '--root-receipt', str(args.root_receipt), + '--architecture', described['arch'], + *(['--device', selected] if selected else []), '--output', str(stage / 'root')], check=True) subprocess.run([sys.executable, '-m', 'orchard.image.initramfs', '--root', str(stage / 'root'), '--output', str(initramfs)], check=True) - subprocess.run([sys.executable, '-m', 'orchard.image.assemble', + assembly = 'orchard.image.cascadia' if profile is not None else 'orchard.image.assemble' + subprocess.run([sys.executable, '-m', assembly, + *(['--profile', str(profile)] if profile is not None else []), + *(['--identity-policy', str(stage / 'root/etc/orchard/recovery-identity.json')] + if (stage / 'root/etc/orchard/recovery-identity.json').is_file() else []), '--manifest', str(HERE / manifest), '--loader', str(args.loader), *(f'--dtb={dtb}' for dtb in args.dtb), *(['--logo', str(args.logo)] if args.logo else []), diff --git a/src/orchard/image/runtime.py b/src/orchard/image/runtime.py index 577717ae..595f3bec 100644 --- a/src/orchard/image/runtime.py +++ b/src/orchard/image/runtime.py @@ -63,7 +63,8 @@ def recovery_files(): prefix = 'usr/libexec/orchard/' source = PROJECT / 'runtime/recovery' result = {prefix + name: ((source / name).read_bytes(), 0o755) - for name in ('device.py', 'identity.py', 'storage-fence', 'start.py')} + for name in ('device.py', 'identity.py', 'policy.py', 'storage-fence', 'start.py')} + result[prefix + 'recovery_identity.py'] = ((PROJECT / 'src/orchard/recovery_identity.py').read_bytes(), 0o644) result[prefix + 'adt.py'] = ((PROJECT / 'src/orchard/apple/adt.py').read_bytes(), 0o644) result['usr/bin/orchard-device'] = (b'#!/bin/sh\nexec /usr/bin/python3 /usr/libexec/orchard/device.py "$@"\n', 0o755) return result diff --git a/src/orchard/inputs.py b/src/orchard/inputs.py index 3d84b442..1d9d7ba0 100644 --- a/src/orchard/inputs.py +++ b/src/orchard/inputs.py @@ -32,30 +32,57 @@ TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw', 'orchard-write', 'resize2fs', 'tune2fs') -def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None): - """The inputs document for PLATFORMS (default: every arm64 platform). +def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None, + platform_loaders=None, platform_tools=None, device=None): + """Resolve one architecture's inputs; default to the existing arm64 platforms. - SHARED: {'m1n1', 'pongo', 'tools'} given explicitly; the rest is resolved. - KERNEL_TREE: a developer's kernel tree to build every selected platform's kernel from. + Per-platform loader/tool overrides keep offline builds independent of + orchard's cache. A root build must choose platforms of one architecture. """ - from .build import prepare_shared + from .build import prepare_shared, prepare_payload_loader, prepare_tools from .hardware import ROOT, revision from .image import platform + from .kernel import descriptor, layout hardware = Path(hardware or ROOT) explicit = explicit or {} - known = platform.platforms(hardware) - selected = list(platforms or known) - unknown = sorted(set(selected) - set(known)) + sorted(set(explicit) - set(selected)) + platform_loaders, platform_tools = platform_loaders or {}, platform_tools or {} + known = platform.platforms(hardware, architecture=None) + if device is not None: + selected_platform, _ = layout(hardware, device) + if platforms and list(platforms) != [selected_platform]: + raise ValueError('Selected device does not match the requested platform') + selected = [selected_platform] + else: + selected = list(platforms or platform.platforms(hardware)) + unknown = (set(selected) - set(known)) | ((set(explicit) | set(platform_loaders) | + set(platform_tools)) - set(selected)) if unknown: - raise ValueError(f'Unknown or unselected platform {", ".join(unknown)}; known: {", ".join(known)}') + raise ValueError(f'Unknown or unselected platform {", ".join(sorted(unknown))}; known: {", ".join(known)}') + descriptions = {name: descriptor(hardware, name) for name in selected} + architectures = {description['arch'] for description in descriptions.values()} + if len(architectures) != 1: + raise ValueError('A distribution root requires platforms of one architecture') + architecture = next(iter(architectures)) + board_sets = {name: platform.boards(name, hardware) for name in selected} + if device is not None: + board_sets[selected[0]] = {device: board_sets[selected[0]][device]} + for name, description in descriptions.items(): + if description['loader'] == 'cascadia' and len(board_sets[name]) != 1: + raise ValueError('Cascadia recovery requires --device to select one board profile') given = {key: Path(value) for key, value in (shared or {}).items() if value is not None} - found = {} if len(given) == 3 else prepare_shared(hardware, cache) - m1n1 = given.get('m1n1') or found['loader'] - pongo = given.get('pongo') or found['pongo'] - tools = given.get('tools') or Path(found['tools']) - for path in (m1n1, pongo, *(tools / name for name in TOOLS)): - if not Path(path).is_file(): - raise ValueError(f'Missing component {path}') + shared_document = {} + if architecture == 'arm64': + found = {} if len(given) == 3 else prepare_shared(hardware, cache) + m1n1 = given.get('m1n1') or found['loader'] + pongo = given.get('pongo') or found['pongo'] + tools = given.get('tools') or Path(found['tools']) + shared_document = {'m1n1': str(Path(m1n1).resolve()), 'pongo': str(Path(pongo).resolve()), + 'tools': {name: str((tools / name).resolve()) for name in TOOLS}} + for path in (m1n1, pongo, *(tools / name for name in TOOLS)): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + elif given: + raise ValueError('ARMv7 inputs use --platform-loader and --platform-tools overrides') try: orchard = revision() except ValueError: @@ -65,29 +92,46 @@ def bundle(name): return explicit[name] if kernel_tree is not None: from .build import prepare_kernel - return prepare_kernel(hardware, cache, device=sorted(platform.boards(name, hardware))[0], + return prepare_kernel(hardware, cache, device=sorted(board_sets[name])[0], kernel_tree=kernel_tree) return platform.kernel(name, hardware=hardware, cache=cache) - return { - 'schema': SCHEMA, - 'orchard': orchard, - 'platforms': {name: {'kernel': str(Path(bundle(name)).resolve()), - 'boards': platform.boards(name, hardware)} - for name in selected}, - 'shared': {'m1n1': str(Path(m1n1).resolve()), 'pongo': str(Path(pongo).resolve()), - 'tools': {name: str((tools / name).resolve()) for name in TOOLS}}, - } + records = {} + for name in selected: + description = descriptions[name] + board = sorted(board_sets[name])[0] + if architecture == 'arm64': + loader = platform_loaders.get(name) or m1n1 + target_tools = platform_tools.get(name) or tools + else: + loader = platform_loaders.get(name) or prepare_payload_loader(hardware, cache, device=board) + target_tools = platform_tools.get(name) or prepare_tools(hardware, cache, device=board) + target_tools = Path(target_tools) + for path in (loader, *(target_tools / tool for tool in TOOLS)): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + records[name] = {'kernel': str(Path(bundle(name)).resolve()), + 'boards': board_sets[name], + 'architecture': description['arch'], 'image': description['image'], + 'loader': {'kind': description['loader'], 'file': str(Path(loader).resolve())}, + 'tools': {tool: str((target_tools / tool).resolve()) for tool in TOOLS}} + return {'schema': SCHEMA, 'orchard': orchard, 'architecture': architecture, + 'platforms': records, 'shared': shared_document} def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) parser.add_argument('--output', type=Path, required=True, help='the JSON document to write') + parser.add_argument('--device', help='restrict inputs to this board and its loader profile') parser.add_argument('--platform', action='append', help='a platform to build for; repeat (default: every one)') parser.add_argument('--kernel-dir', action='append', default=[], metavar='PLATFORM=DIRECTORY', help="a platform's staged kernel bundle instead of orchard's") parser.add_argument('--m1n1', type=Path, help="orchard's m1n1 to use as it is") parser.add_argument('--pongo', type=Path, help='the Pongo to use as it is') parser.add_argument('--tools', type=Path, help='a directory with the static tools to use as they are') + parser.add_argument('--platform-loader', action='append', default=[], metavar='PLATFORM=FILE', + help='an explicit payload loader for one platform') + parser.add_argument('--platform-tools', action='append', default=[], metavar='PLATFORM=DIRECTORY', + help='an explicit static tool directory for one platform') parser.add_argument('--kernel-tree', type=Path, help='build the kernels from this kernel tree ' '(default: ORCHARD_KERNEL_TREE, which orchard build --kernel-tree sets)') args = parser.parse_args() @@ -97,6 +141,16 @@ def main(): if not separator: parser.error('--kernel-dir takes PLATFORM=DIRECTORY') explicit[name] = Path(path) + def overrides(values, option): + result = {} + for value in values: + name, separator, path = value.partition('=') + if not separator or not name or not path or name in result: + parser.error(option + ' takes distinct PLATFORM=PATH values') + result[name] = Path(path) + return result + platform_loaders = overrides(args.platform_loader, '--platform-loader') + platform_tools = overrides(args.platform_tools, '--platform-tools') try: import os tree = args.kernel_tree or (Path(os.environ['ORCHARD_KERNEL_TREE']) if os.environ.get('ORCHARD_KERNEL_TREE') @@ -105,7 +159,8 @@ def main(): explain.start() try: document = resolve(args.platform, explicit, shared={'m1n1': args.m1n1, 'pongo': args.pongo, - 'tools': args.tools}, kernel_tree=tree) + 'tools': args.tools}, kernel_tree=tree, + platform_loaders=platform_loaders, platform_tools=platform_tools, device=args.device) finally: steps = explain.stop() except ValueError as error: diff --git a/src/orchard/project.py b/src/orchard/project.py index d084147a..3bcf8343 100644 --- a/src/orchard/project.py +++ b/src/orchard/project.py @@ -10,6 +10,7 @@ [release] build = "tools/build.py" # relative to this file selects-platform = true # optional: the script takes --platform + selects-device = true # optional: preserve the chosen board as --device The file says what the project builds, never which board it is for; a developer's choices (platform, device, kernel tree) are arguments or diff --git a/src/orchard/ram.py b/src/orchard/ram.py index d0ba1eac..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/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/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c new file mode 100644 index 00000000..791d542f --- /dev/null +++ b/tests/fixtures/cascadia-runtime.c @@ -0,0 +1,131 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Exercise the producer's actual C entry against anonymous mapped RAM/MMIO. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +/* The assembler patches this record in writable upload RAM on the device. */ +#define const +#include "linux_boot.c" +#undef const + +static unsigned int checks; + +#define CHECK(test) do { \ + checks++; \ + if (!(test)) { fprintf(stderr, "line %d: %s\n", __LINE__, #test); exit(1); } \ +} while (0) + +static void *memory(uintptr_t address, size_t bytes) +{ + void *value = mmap((void *)address, bytes, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0); + CHECK(value == (void *)address); + return value; +} + +static void prepare(void) +{ + struct config value = { + { 'C', 'B', '3', '2' }, 1, sizeof(struct config), LOAD_ADDRESS, + KERNEL_OFFSET, 0x9000, 40, 0xa000, 33, INITRD_ADDRESS, + 0xa000 + 33, WATCHDOG_ADDRESS, L2_ADDRESS, + RAM_START, RAM_END, INITRD_LIMIT, 0, + }; + memcpy((void *)&cascadia_config, &value, sizeof(value)); + unsigned char *ram = (void *)(uintptr_t)LOAD_ADDRESS; + memset(ram, 0, 0x20000); + ram[0x9000] = 0xd0; + ram[0x9001] = 0x0d; + ram[0x9002] = 0xfe; + ram[0x9003] = 0xed; + ram[0x9007] = 40; + for (unsigned int i = 0; i < 33; i++) + ram[0xa000 + i] = (unsigned char)(i ^ 0x5a); + memset((void *)(uintptr_t)INITRD_ADDRESS, 0x77, 0x10000); + memset((void *)(uintptr_t)WATCHDOG_ADDRESS, 0xa5, 16); + cascadia_config.crc32 = checksum(); +} + +static void rejects(void) +{ + CHECK(cascadia_main() == 0); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == 0x77); + for (unsigned int i = 0; i < 16; i++) + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); +} + +int main(void) +{ + CHECK(sizeof(u32) == 4 && sizeof(struct config) == 68); + memory(LOAD_ADDRESS, 0x2000000); + memory(INITRD_ADDRESS, 0x2000000); + memory(WATCHDOG_ADDRESS & ~4095u, 4096); + prepare(); + /* Validation must work even when high RAM has no inherited mapping. */ + CHECK(mprotect((void *)(uintptr_t)INITRD_ADDRESS, 0x2000000, PROT_NONE) == 0); + CHECK(cascadia_main() == LOAD_ADDRESS + 0x9000); + CHECK(mprotect((void *)(uintptr_t)INITRD_ADDRESS, 0x2000000, + PROT_READ | PROT_WRITE) == 0); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == 0x77); + cascadia_relocate_initrd(LOAD_ADDRESS + 0xa000, INITRD_ADDRESS, 33); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == (i ^ 0x5a)); + for (unsigned int i = 0; i < 16; i++) + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[33] == 0x77); + + /* Exercise exact word/tail sizes and the real attempt-3 initramfs length. */ + const unsigned int sizes[] = { 0, 1, 2, 3, 4, 7, 8, 33, 4096, 4097, 19124158 }; + unsigned char *source = (void *)(uintptr_t)(LOAD_ADDRESS + 0xa000); + unsigned char *target = (void *)(uintptr_t)(INITRD_ADDRESS + 16); + unsigned char *guard = (void *)(uintptr_t)(INITRD_ADDRESS + 12); + for (unsigned int test = 0; test < sizeof(sizes) / sizeof(sizes[0]); test++) { + unsigned int size = sizes[test]; + for (unsigned int i = 0; i < size + 4; i++) + source[i] = (unsigned char)(i ^ (i >> 8) ^ 0x5a); + memset(target - 4, 0x77, size + 8); + cascadia_relocate_initrd(LOAD_ADDRESS + 0xa000, INITRD_ADDRESS + 16, size); + CHECK(memcmp(source, target, size) == 0); + for (unsigned int i = 0; i < 4; i++) { + CHECK(guard[i] == 0x77); + CHECK(target[size + i] == 0x77); + } + } + + /* Every field is either profile-fixed or has a checked dynamic bound. */ + volatile u32 *fields = (volatile u32 *)&cascadia_config; + for (unsigned int i = 0; i < sizeof(struct config) / sizeof(u32); i++) { + prepare(); + fields[i] = 0xffffffffu; + if (i != 16) + cascadia_config.crc32 = checksum(); + rejects(); + } + prepare(); + cascadia_config.dtb_offset = KERNEL_OFFSET - 4; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + cascadia_config.initrd_offset = 0x9000; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + cascadia_config.initrd_offset++; + cascadia_config.image_size++; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + ((unsigned char *)(uintptr_t)(LOAD_ADDRESS + 0x9000))[3] ^= 1; + rejects(); + prepare(); + ((unsigned char *)(uintptr_t)(LOAD_ADDRESS + 0x9000))[7] ^= 1; + rejects(); + printf("%u runtime checks passed\n", checks); + return 0; +} diff --git a/tests/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..94a4ba5e --- /dev/null +++ b/tests/test_armv7_producers.py @@ -0,0 +1,281 @@ +"""ARMv7 producer boundaries use synthetic ELF and DTB inputs, with no device access.""" +import gzip +import hashlib +import io +import json +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest +from unittest import mock + +from orchard import build, inputs, distro +from orchard.hardware import ROOT +from orchard.image import elf, platform, prepare_runtime + + +def executable(architecture='arm', dynamic=False): + width = 1 if architecture == 'arm' else 2 + data = bytearray(384) + data[:7] = b'\x7fELF' + bytes((width, 1, 1)) + struct.pack_into('I', cells), + 'compatible': b'example,test\0'}, + 'children': [{'name': 'chosen', 'properties': {'bootargs': b'old\0', 'stdout-path': b'serial0\0'}, + 'children': []}, + {'name': 'memory@80000000', 'properties': {'device_type': b'memory\0', + 'reg': b'\x80\0\0\0\x20\0\0\0'}, + 'children': []}]} + + +class CascadiaLoaderTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == 'linux' and shutil.which('cc'), 'The C runtime fixture needs Linux mmap and a C compiler') + def test_actual_runtime_c_copy_and_rejection_paths(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + (folder / 'cascadia-generated.h').write_text(''.join( + f'#define {name.upper()} {hex(value)}\n' for name, value in PROFILE.items() if name != 'schema')) + executable = folder / 'runtime-check' + command = ['cc', '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-Wno-int-to-pointer-cast', + '-I', str(folder), '-I', str(ROOT / 'boot/cascadia'), + str(ROOT / 'tests/fixtures/cascadia-runtime.c'), '-o', str(executable)] + compiled = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('runtime checks passed', result.stdout) + + def test_elf_entry_and_raw_code_must_match(self): + binary = loader() + elf = executable(binary) + cascadia.verify_loader(binary, PROFILE, elf) + shifted = bytearray(elf) + struct.pack_into('I', 0x98000000), + 'linux,initrd-end': struct.pack('>I', 0x98000010)}) + old = cascadia.write_fdt(value, [(0x98000000, 16), (0x9f000000, 4096)]) + new = cascadia.chosen(old, 'new', 0x98000000, 32) + self.assertEqual(cascadia.read_fdt(new)[1], [(0x9f000000, 4096), (0x98000000, 32)]) + + def test_reserved_destination_refused(self): + old = cascadia.write_fdt(tree(), [(PROFILE['initrd_address'] + 4, 4096)]) + with self.assertRaisesRegex(ValueError, 'reserved memory'): + self.assemble(dtb=old) + + def test_fdt_corruption_is_rejected(self): + original = cascadia.write_fdt(tree()) + for offset, value in ((4, len(original) + 1), (8, 0xffffffff), (12, 0), (16, 41), (20, 16)): + with self.subTest(offset=offset): + bad = bytearray(original) + struct.pack_into('>I', bad, offset, value) + with self.assertRaises(ValueError): + cascadia.read_fdt(bad) + with self.assertRaisesRegex(ValueError, 'overlap'): + bad = bytearray(original) + struct.pack_into('>I', bad, 12, struct.unpack_from('>I', bad, 8)[0]) + cascadia.read_fdt(bad) + + def test_wrong_kernel_and_out_of_bounds_initramfs_refused(self): + with self.assertRaisesRegex(ValueError, 'ARM zImage'): + self.assemble(kernel=b'arm64 Image') + with self.assertRaisesRegex(ValueError, 'length'): + self.assemble(kernel=kernel() + b'appended old dtb') + smaller = dict(PROFILE, initrd_limit=PROFILE['initrd_address'] + 8) + with self.assertRaisesRegex(ValueError, 'destination'): + self.assemble(loader=loader(smaller), described=smaller, elf=None) + with self.assertRaisesRegex(ValueError, 'one line'): + self.assemble(bootargs='a\nb') + + def test_decompressor_workspace_cannot_reach_high_initramfs(self): + large = bytearray(kernel()) + struct.pack_into('