Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ A small Alpine-based Linux image for Apple devices, demonstrating the Orchard re

Currently supports iPad 6 Wi-Fi (`iPad7,5`, J71b), experimentally. Boot is tethered and requires a host computer. This is a development/reference image, not a general-purpose tablet distribution.

The [P105 recovery composer](p105/README.md) adds a private ARMv7 RAM environment
for the original iPad mini Wi-Fi. It works with hardware 0.1.11 and the native
Orchard CLI adapter for tethered boot and handoff to an existing NFS desktop.

## Local development

CLI and hardware sources are Git submodules in `cli/` and `hardware/`.
Expand Down
2 changes: 1 addition & 1 deletion hardware
11 changes: 11 additions & 0 deletions p105/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Native ARM64 apk verifies ARMv7 packages with the official ARMv7 signing keys.
FROM docker.io/library/alpine@sha256:6db0aa37b0f1ba86c31102b0972cb082a1d22ed1390440de84f1076d78d4d399 AS armkeys
FROM docker.io/library/alpine@sha256:e311ae748be7f56ae5cba304bc488f0786df68ee507513f654a5cc8597cffb42
COPY --from=armkeys /etc/apk/keys /armroot/etc/apk/keys
COPY --from=armkeys /etc/apk/keys /etc/apk/keys
RUN apk --root /armroot --arch armv7 --initdb --no-scripts \
--keys-dir /etc/apk/keys --repositories-file /etc/apk/repositories \
add busybox musl python3 dropbear nfs-utils \
&& mkdir -p /armroot/bin \
&& cp /etc/apk/repositories /armroot/etc/apk/repositories \
&& cp -a /etc/apk/keys /armroot/etc/apk/keys
26 changes: 26 additions & 0 deletions p105/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# P105 ARMv7 RAM recovery

`build-base.py` obtains an Alpine ARMv7 base through the signed package indexes.
Its pinned ARM64 container runs the package manager; the official ARMv7 image
supplies the target signing keys. Target programs are extracted without running
package scripts. The receipt records the resulting image and archive hashes.

`prepare-recovery.py` composes a private root from that archive, an explicit
owner initramfs and its matching MtCl capture. Supply the verified target JSON
with `udid` and `ecid`. The result records the selected ECID for Orchard's USB
identity and the device protocol. Firmware and calibration remain private.

The composer clears inherited SSH keys. Recovery generates its diagnostic key
after a host provisions access through the physical serial console. A small
closure of the ARMv7 SSH executable and its libraries can travel in RAM through
the NFS pivot, preserving its pinned key across userspaces.

Pass the composed root to the hardware package's `kernel/p105/build` as
`--private-initramfs`, with the separate `--calibration` input. That producer
installs the recovery init and selects its kernel USB settings while retaining
the P105 flash fence. The companion CLI's `docs/p105.md` describes private
manifest assembly and the native boot workflow.

The NFS root needs the hardware package's P105 systemd runtime and Python 3.9
or later. It can contain the preserved Arch / Omarchy A desktop during this
phase. The ARMv7 desktop release builder remains a later contribution.
33 changes: 33 additions & 0 deletions p105/build-base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Export signed Alpine ARMv7 packages for the P105 RAM recovery."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
output = args.output.absolute()
receipt = output.with_suffix('.json')
if output.exists() or output.is_symlink() or receipt.exists():
parser.error('Choose a fresh base output')
output.parent.mkdir(parents=True, exist_ok=True)
recipe = Path(__file__).with_name('Containerfile')
tag = 'localhost/orchard-p105-recovery-base:alpine'
subprocess.run(['podman', 'build', '-t', tag, '-f', str(recipe), str(recipe.parent)], check=True)
image = subprocess.check_output(['podman', 'image', 'inspect', tag, '--format', '{{.Id}}'], text=True).strip()
with output.open('xb') as stream:
subprocess.run(['podman', 'run', '--rm', '--network', 'none', image,
'tar', '-C', '/armroot', '-cf', '-', '.'], stdout=stream, check=True)
receipt.write_text(json.dumps({'schema': 1, 'architecture': 'armv7h',
'signed_apk': True, 'image': image, 'bytes': output.stat().st_size,
'sha256': hashlib.sha256(output.read_bytes()).hexdigest(),
'recipe_sha256': hashlib.sha256(recipe.read_bytes()).hexdigest()}, indent=2)+'\n')


if __name__ == '__main__':
main()
182 changes: 182 additions & 0 deletions p105/prepare-recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Compose an owner-bound ARMv7 recovery root from signed Alpine packages."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import struct
import tarfile

import orchard_hardware


def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()


def extract(archive, root):
"""Normalize guest links, and refuse archive writes through outside paths."""
with tarfile.open(archive) as stream:
for member in stream:
name = member.name.removeprefix('./')
if name in ('', '.'):
continue
if name.startswith('/') or '..' in Path(name).parts:
raise ValueError('Unsafe base archive path')
path = root/name
if not path.parent.resolve().is_relative_to(root.resolve()):
raise ValueError('Base archive parent escapes its root')
path.parent.mkdir(parents=True, exist_ok=True)
if member.isdir():
path.mkdir(exist_ok=True)
elif member.issym():
target = root/member.linkname.lstrip('/') if member.linkname.startswith('/') else path.parent/member.linkname
if not target.resolve().is_relative_to(root.resolve()):
raise ValueError('Base archive link escapes its root')
if path.is_dir() and not path.is_symlink():
raise ValueError('Base archive would replace a directory with a link')
path.unlink(missing_ok=True)
path.symlink_to(os.path.relpath(target, path.parent))
elif member.isfile():
if path.is_symlink():
path.unlink()
with stream.extractfile(member) as source, path.open('wb') as destination:
shutil.copyfileobj(source, destination)
path.chmod(member.mode & 0o777)
elif member.islnk():
target = root/member.linkname.removeprefix('./')
if not target.resolve().is_relative_to(root.resolve()) or not target.is_file():
raise ValueError('Unsafe base archive hard link')
path.unlink(missing_ok=True)
shutil.copyfile(target, path)
else:
raise ValueError('Special file in recovery base')


def needed32(data):
if data[:7] != b'\x7fELF\x01\x01\x01' or data[18:20] != b'\x28\x00':
raise ValueError('Expected ARM32 ELF')
offset = struct.unpack_from('<I', data, 28)[0]
size, count = struct.unpack_from('<HH', data, 42)
loads, dynamic = [], None
for index in range(count):
kind, start, virtual, _, length, _, _, _ = struct.unpack_from('<IIIIIIII', data, offset+index*size)
if kind == 1:
loads.append((virtual, start, length))
elif kind == 2:
dynamic = start, length
names, strings = [], None
if dynamic is None:
return set()
for index in range(dynamic[0], sum(dynamic), 8):
tag, value = struct.unpack_from('<II', data, index)
if tag == 0:
break
if tag == 1:
names.append(value)
if tag == 5:
strings = value
for virtual, start, length in loads:
if strings is not None and virtual <= strings < virtual+length:
start += strings-virtual
return {data[start+name:data.index(b'\0', start+name)].decode('ascii') for name in names}
if names:
raise ValueError('ELF strings outside load segments')
return set()


def ssh_closure(root):
output = root/'usr/libexec/orchard-p105/ssh'
(output/'lib').mkdir(parents=True)
binary = root/'usr/sbin/dropbear'
shutil.copyfile(binary, output/'dropbear')
(output/'dropbear').chmod(0o755)
pending = needed32(binary.read_bytes()) | {'ld-musl-armhf.so.1'}
done = set()
while pending:
name = pending.pop()
if '/' in name:
raise ValueError('Unexpected SSH library name')
candidates = [p for directory in ('lib', 'usr/lib') if (p := root/directory/name).is_file()]
if not candidates:
raise ValueError('Missing ARMv7 SSH library: '+name)
source = candidates[0]
shutil.copyfile(source, output/'lib'/name)
(output/'lib'/name).chmod(0o755)
done.add(name)
pending |= needed32(source.read_bytes())-done
return sorted(done)


def prepare(args):
root = args.output.expanduser().absolute()
project = Path(__file__).resolve().parents[1]
if root.exists() or root.is_symlink() or root.resolve().is_relative_to(project):
raise ValueError('Choose a fresh private root outside the project')
if root.resolve().is_relative_to(args.initramfs.resolve()):
raise ValueError('Keep the recovery output outside its owner input tree')
receipt = json.loads(args.base_receipt.read_text())
if (receipt.get('architecture') != 'armv7h' or not receipt.get('signed_apk') or
args.base.stat().st_size != receipt['bytes'] or sha(args.base) != receipt['sha256']):
raise ValueError('Recovery base differs from its signed-package receipt')
target = json.loads(args.target.read_text())
if isinstance(target.get('ecid'), str) and re.fullmatch(r'(?:0[xX][0-9a-fA-F]+|[0-9]+)', target['ecid']):
target['ecid'] = int(target['ecid'], 16 if target['ecid'].lower().startswith('0x') else 10)
if type(target.get('ecid')) is not int or not 0 < target['ecid'] < 2**64:
raise ValueError('Expected the owner target identity')
calibration = args.calibration.read_bytes()
if not calibration or (args.initramfs/'lib/firmware/mtcal.bin').read_bytes() != calibration:
raise ValueError('Recovery root and owner calibration differ')
os.umask(0o077)
shutil.copytree(args.initramfs, root, symlinks=True)
extract(args.base, root)
for relative in ('root/.ssh', 'etc/dropbear'):
path = root/relative
if path.is_symlink():
path.unlink()
elif path.exists():
shutil.rmtree(path)
for directory in ('dev','proc','sys','run','tmp','root','.ssh','etc/orchard'):
(root/directory).mkdir(parents=True, exist_ok=True)
(root/'etc/passwd').write_text('root:x:0:0:root:/root:/bin/sh\n')
(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)
for name in ('sh','mount','umount','ip','mkdir','cat','chmod','date','ln','basename','touch',
'kill','sleep','ls','mv','cp','chown','grep','awk','sha256sum','hostname','head','getty'):
path = root/'bin'/name
path.unlink(missing_ok=True)
path.symlink_to('busybox.static')
resources = Path(orchard_hardware.__file__).parent/'runtime/p105/recovery'
runtime = root/'usr/libexec/orchard-p105'
runtime.mkdir(parents=True, exist_ok=True)
shutil.copyfile(resources/'device.py', runtime/'device.py')
wrapper = root/'usr/bin/orchard-device'
wrapper.write_text('#!/bin/sh\nexec /usr/bin/python3 /usr/libexec/orchard-p105/device.py "$@"\n')
wrapper.chmod(0o755)
config = {'schema': 1, 'profile': 'p105', 'ecid': target['ecid'], 'release': args.release,
'calibration_sha256': hashlib.sha256(calibration).hexdigest(),
'base_sha256': receipt['sha256']}
(root/'etc/orchard/p105.json').write_text(json.dumps(config)+'\n')
libraries = ssh_closure(root)
needed32((root/'usr/bin/python3').read_bytes())
result = {'schema':1, 'private':True, 'publishable':False, 'profile':'p105',
'architecture':'armv7h', 'ecid':target['ecid'], 'base':receipt,
'ssh_libraries':libraries, 'release':args.release}
(root.parent/(root.name+'.json')).write_text(json.dumps(result,indent=2)+'\n')


def main():
parser = argparse.ArgumentParser(description=__doc__)
for name in ('base','base-receipt','initramfs','calibration','target','output'):
parser.add_argument('--'+name, type=Path, required=True)
parser.add_argument('--release', default='p105-recovery-dev')
args = parser.parse_args()
prepare(args)


if __name__ == '__main__':
main()
56 changes: 56 additions & 0 deletions tests/test_p105_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Public archive and ARM dependency checks for the P105 root composer."""
import importlib.util
import argparse
import io
from pathlib import Path
import tarfile
import tempfile
import unittest

SPEC=importlib.util.spec_from_file_location('p105_root_composer',Path(__file__).resolve().parents[1]/'p105/prepare-recovery.py')
composer=importlib.util.module_from_spec(SPEC);SPEC.loader.exec_module(composer)


class P105Root(unittest.TestCase):
def test_output_inside_owner_source_is_rejected_before_reading_inputs(self):
with tempfile.TemporaryDirectory() as directory:
source=Path(directory).resolve()/'owner';source.mkdir()
args=argparse.Namespace(output=source/'output',initramfs=source)
with self.assertRaisesRegex(ValueError,'outside its owner input'):
composer.prepare(args)
self.assertEqual(list(source.iterdir()),[])

def test_archive_traversal_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
base=Path(directory);archive=base/'base.tar';root=base/'root';root.mkdir()
with tarfile.open(archive,'w') as stream:
member=tarfile.TarInfo('../escape');member.size=1
stream.addfile(member,io.BytesIO(b'x'))
with self.assertRaisesRegex(ValueError,'Unsafe'):composer.extract(archive,root)
self.assertFalse((base/'escape').exists())

def test_archive_parent_link_cannot_write_outside_root(self):
with tempfile.TemporaryDirectory() as directory:
base=Path(directory);root=base/'root';root.mkdir();outside=base/'outside';outside.mkdir()
(root/'lib').symlink_to(outside,target_is_directory=True)
archive=base/'base.tar'
with tarfile.open(archive,'w') as stream:
member=tarfile.TarInfo('lib/escape');member.size=1
stream.addfile(member,io.BytesIO(b'x'))
with self.assertRaisesRegex(ValueError,'escapes'):composer.extract(archive,root)
self.assertFalse((outside/'escape').exists())

def test_absolute_guest_symlink_stays_in_guest(self):
with tempfile.TemporaryDirectory() as directory:
base=Path(directory);root=base/'root';root.mkdir();archive=base/'base.tar'
with tarfile.open(archive,'w') as stream:
member=tarfile.TarInfo('bin/sh');member.type=tarfile.SYMTYPE;member.linkname='/bin/busybox'
stream.addfile(member)
composer.extract(archive,root)
self.assertEqual((root/'bin/sh').readlink(),Path('busybox'))

def test_target_dependency_parser_rejects_host_executables(self):
with self.assertRaisesRegex(ValueError,'ARM32'):composer.needed32(b'\x7fELF\x02\x01'+b'\0'*100)


if __name__ == '__main__':unittest.main()
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.