Skip to content
Merged
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
6 changes: 5 additions & 1 deletion boot/initramfs/root/init
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,17 @@ usb_gadget() {
[ ! -e "$g" ] || return 0
name=$(sed -n 's/^NAME=//p' /newroot/etc/os-release /newroot/usr/lib/os-release 2>/dev/null | head -1 | tr -d "\"'")
board=$(tr '\000' '\n' < /proc/device-tree/compatible | head -1)
# One serial for every boot of this unit (docs/device-protocol.md), or
# the per-boot one if the helper fails: USB must always come up.
serial=$(sh /etc/orchard/usb-identity 2>/dev/null) ||
serial="BOOT:$(cat /proc/sys/kernel/random/boot_id) BOARD:${board#apple,}"
mkdir "$g" &&
echo 0x05ac > "$g/idVendor" &&
echo 0x4145 > "$g/idProduct" &&
mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409" &&
echo 'Orchard Linux' > "$g/strings/0x409/manufacturer" &&
echo "${name:-Linux}" > "$g/strings/0x409/product" &&
echo "BOOT:$(cat /proc/sys/kernel/random/boot_id) BOARD:${board#apple,}" > "$g/strings/0x409/serialnumber" &&
echo "$serial" > "$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" &&
Expand Down
18 changes: 18 additions & 0 deletions docs/device-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ the host and device side of an install always come from the same CLI version.
- Private bundle: exactly the device firmware data plus `mixin.json`; the
install script writes it to the installed root's `/usr/lib/firmware`.

## USB identity

Installed-system and standard RAM gadgets use `BOARD:<profile> ID:<hash>` as
their USB serial number. The ID is the first 12 hex characters of SHA-256 over
`orchard-` followed by the device-tree `serial-number` without NUL
padding. It identifies the unit across boots without publishing the hardware
serial. No descriptor changes per boot, so macOS can retain accessory approval.
Trees without a serial number warn and retain the legacy `BOOT:` identity to
keep rescue USB available; those still need approval each boot.

For RAM sessions, ACM returns `/proc/sys/kernel/random/boot_id` and the SSH
host public key together on every connection. The host validates the boot ID
and pins that boot's key. Installed systems use their persistent SSH keys and
do not allocate per-boot host state. Recovery and install keep their existing
`ECID:`/`BOOT:` serials and bootstrap/status checks. The install-to-system
handoff also retains `BOOT:`: the CLI's install add-on can run on older initrds
without the stable-identity helper. Normal installed boots use the new format.

## Operations

`orchard-device OPERATION [BASE64_JSON]`; requests may include `protocol: 1`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ package-dir = {"" = "src"}
where = ["src"]

[tool.setuptools.package-data]
orchard = ["py.typed", "usb-session"]
orchard = ["py.typed", "usb-session", "usb-identity"]

[dependency-groups]
dev = ["mypy==1.18.2", "jsonschema>=4.25,<5"]
Expand Down
8 changes: 8 additions & 0 deletions src/orchard/apple/linux_usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ def serial_tags(serial):
'pwned': pwned.group(1) if pwned else ('checkra1n' if 'YOLO:checkra1n' in serial else None)}


def stable_id(serial):
"""Hashed per-device identity, absent on legacy gadgets."""
match = re.search(r'\bID:([0-9a-fA-F]{12})\b', serial)
return match.group(1).lower() if match else None


def devices(root=Path('/sys/bus/usb/devices')):
found = []
for node in root.iterdir():
Expand All @@ -45,6 +51,7 @@ def devices(root=Path('/sys/bus/usb/devices')):
ecid = re.search(r'\bECID:([0-9a-fA-F]+)\b', serial)
chip = re.search(r'\bCPID:([0-9a-fA-F]+)\b', serial)
board = re.search(r'\bBDID:([0-9a-fA-F]+)\b', serial)
usb_id = stable_id(serial)
profile = re.search(r'\bBOARD:([a-z0-9]+)\b', serial)
ports = {str(Path('/dev') / p.name) for p in node.glob('**/tty/ttyACM*')}
if len(ports) > 1:
Expand All @@ -53,6 +60,7 @@ def devices(root=Path('/sys/bus/usb/devices')):
found.append(dict(location=node.name, mode=MODES.get(pid, 'other'),
ecid=int(ecid.group(1), 16) if ecid else None,
boot_id=str(uuid.UUID(boot.group(1))) if boot else None,
usb_id=usb_id,
chip=int(chip.group(1), 16) if chip else None,
board=int(board.group(1), 16) if board else None,
profile=profile.group(1) if profile else None, product=product,
Expand Down
4 changes: 3 additions & 1 deletion src/orchard/apple/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import sys
import uuid

from .linux_usb import MODES, M1N1, devices as linux_devices, m1n1_ports as linux_m1n1_ports, serial_tags
from .linux_usb import MODES, M1N1, devices as linux_devices, m1n1_ports as linux_m1n1_ports, serial_tags, stable_id
from orchard.state import EndpointUnavailable


Expand Down Expand Up @@ -33,6 +33,7 @@ def devices():
continue
serial = node.get('USB Serial Number') or ''
boot = re.search(r'\bBOOT:([0-9a-fA-F-]{36})\b', serial)
usb_id = stable_id(serial)
profile = re.search(r'\bBOARD:([a-z0-9]+)\b', serial)
fields = [re.search(r'\b' + label + r':([0-9a-fA-F]+)\b', serial)
for label in ('ECID', 'CPID', 'BDID')]
Expand All @@ -50,6 +51,7 @@ def devices():
found.append({'location': location, 'mode': MODES.get(node.get('idProduct'), 'other'),
'ecid': int(fields[0].group(1), 16) if fields[0] else None,
'boot_id': str(uuid.UUID(boot.group(1))) if boot else None,
'usb_id': usb_id,
'chip': int(fields[1].group(1), 16) if fields[1] else None,
'board': int(fields[2].group(1), 16) if fields[2] else None,
'profile': profile.group(1) if profile else None,
Expand Down
1 change: 1 addition & 0 deletions src/orchard/image/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_
entries = {name: (directory, b'') for name in ('bin', 'dev', 'proc', 'sys', 'etc', 'etc/orchard', 'newroot',
'lib', 'lib/modules', 'lib/modules/' + release)}
entries['init'] = (stat.S_IFREG | 0o755, (hardware / 'boot/initramfs/root/init').read_bytes())
entries['etc/orchard/usb-identity'] = (stat.S_IFREG | 0o644, (hardware / 'src/orchard/usb-identity').read_bytes())
entries['bin/busybox'] = (stat.S_IFREG | 0o755, Path(busybox).read_bytes())
entries['etc/orchard/modules'] = (stat.S_IFREG | 0o644, ('\n'.join(loaded) + '\n').encode())
if usb:
Expand Down
43 changes: 31 additions & 12 deletions src/orchard/ram_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import subprocess
import sys
import time
import uuid

from .serial import SerialError, SerialPort
from .state import cache_root, private_directory
Expand Down Expand Up @@ -49,7 +50,9 @@ def identity():
def payload_files():
"""RAM-root files that enable the USB session in a payload."""
helper = resources.files('orchard') / 'usb-session'
return {HELPER: Path(str(helper)), AUTHORIZED: identity().with_suffix('.pub')}
return {HELPER: Path(str(helper)),
'usr/libexec/orchard/usb-identity': Path(str(resources.files('orchard') / 'usb-identity')),
AUTHORIZED: identity().with_suffix('.pub')}


def target():
Expand All @@ -69,17 +72,20 @@ def target():


def _check(device):
# Stable descriptors identify the unit, not its boot. RAM SSH is pinned to
# the per-boot key read on the open ACM endpoint; a reboot invalidates it.
fields = ('location', 'mode', 'profile', 'usb_id', 'boot_id', 'serial_port')
def check():
from .apple import usb
if not any(item['location'] == device['location'] and item.get('boot_id') == device.get('boot_id')
if not any(all(item.get(key) == device.get(key) for key in fields)
for item in usb.devices()):
raise ValueError('The RAM payload disconnected or rebooted')
return check


def connect(device=None, user=None, *, host=None, ram=False):
"""SSH argv for the running payload (its host key pinned over serial once
per boot) or installed system (as its user, with this computer's key)."""
"""SSH argv for the running payload (its boot and host key read over ACM)
or installed system (as its user, with this computer's key)."""
if ram and (not host or user):
raise ValueError('--ram requires --host and connects as root, not --user')
if host:
Expand All @@ -106,20 +112,33 @@ def connect(device=None, user=None, *, host=None, ram=False):
argv = computer_key.ssh_argv(user)
wait_authenticated(argv, _check(device), seconds=20)
return argv
boot = device.get('boot_id')
if not boot:
if not (device.get('boot_id') or device.get('usb_id')):
raise ValueError('The RAM payload has no boot identity; rebuild it with a current orchard')
check = _check(device)
# Query every connection: the same stable USB serial can name a new boot.
# ACM supplies both boot ID and trusted host key without changing any USB
# descriptor, and also works with payloads built before stable identities.
with SerialPort(device['serial_port'], check) as serial:
code, text = serial.run('cat /proc/sys/kernel/random/boot_id && '
'cat /run/orchard/ssh_host_ed25519_key.pub', timeout=15)
if code:
raise ValueError('No SSH in this payload; its /init must run '
'/' + HELPER + ' (or build without transport = false)')
lines = text.strip().splitlines()
try:
boot = str(uuid.UUID(lines[0].strip()))
except (ValueError, IndexError) as error:
raise ValueError('Invalid RAM boot identity over USB serial') from error
if device.get('boot_id') and boot != device['boot_id']:
raise ValueError('The RAM payload disconnected or rebooted')
pinned = HOST + ' ' + host_key('\n'.join(lines[1:])) + '\n'
hosts = _directory() / f'known_hosts-{boot}'
if hosts.is_file() and hosts.read_text() != pinned:
raise ValueError('The RAM SSH host key changed within the same boot')
if not hosts.is_file():
with SerialPort(device['serial_port'], check) as serial:
code, text = serial.run('cat /run/orchard/ssh_host_ed25519_key.pub', timeout=15)
if code:
raise ValueError('No SSH in this payload; its /init must run '
'/' + HELPER + ' (or build without transport = false)')
for old in _directory().glob('known_hosts-*'):
old.unlink()
hosts.write_text(HOST + ' ' + host_key(text) + '\n')
hosts.write_text(pinned)
hosts.chmod(0o600)
argv = ssh_argv(identity(), hosts, HOST, 22)
wait_authenticated(argv, check, seconds=20)
Expand Down
24 changes: 24 additions & 0 deletions src/orchard/usb-identity
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
# Stable USB identity, shared by installed-system and RAM gadgets. The boot
# stage copies serial-number from Apple's tree; never publish it in the clear.
set -eu
tree=${ORCHARD_DEVICE_TREE:-/proc/device-tree}
board=$(tr '\000' '\n' < "$tree/compatible" | head -1)
serial=
if [ -r "$tree/serial-number" ]; then
serial=$(tr -d '\000' < "$tree/serial-number")
fi
id=
if [ -n "$serial" ]; then
id=$(printf 'orchard-%s' "$serial" | sha256sum 2>/dev/null | cut -c1-12) || id=
case "$id" in *[!0-9a-f]* | '') id= ;; esac
[ ${#id} -eq 12 ] || id=
fi
if [ -n "$id" ]; then
printf 'BOARD:%s ID:%s\n' "${board#apple,}" "$id"
else
# Keep rescue serial and SSH reachable on trees without a hardware ID.
# This legacy fallback cannot retain the host's accessory approval.
echo 'orchard: no device-tree serial-number; using per-boot USB identity' >&2
printf 'BOOT:%s BOARD:%s\n' "$(cat /proc/sys/kernel/random/boot_id)" "${board#apple,}"
fi
3 changes: 2 additions & 1 deletion src/orchard/usb-session
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ echo 0x05ac > "$g/idVendor"
echo 0x4144 > "$g/idProduct"
mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409"
echo "Orchard $name" > "$g/strings/0x409/product"
echo "BOOT:$(cat /proc/sys/kernel/random/boot_id)" > "$g/strings/0x409/serialnumber"
sh /usr/libexec/orchard/usb-identity > "$g/strings/0x409/serialnumber" 2>/dev/null ||
echo "BOOT:$(cat /proc/sys/kernel/random/boot_id)" > "$g/strings/0x409/serialnumber"
echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration"
# Descriptor only: not charging capability or PMIC policy.
echo 100 > "$g/configs/c.1/MaxPower"
Expand Down
73 changes: 73 additions & 0 deletions tests/test_identity.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Boot identity publication from bounded ADT fixtures; no device activation."""
import importlib.util
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import struct
import sys
import tempfile
Expand Down Expand Up @@ -107,5 +110,75 @@ def test_gadget_publishes_identity_before_binding_usb(self):
self.assertLess(source.index('/serialnumber'),source.index('"$g/UDC"'))


class GadgetIdentity(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.tree = self.root / 'proc/device-tree'
self.tree.mkdir(parents=True)
(self.tree / 'compatible').write_bytes(b'apple,j71t\0apple,s8003\0')
boot = self.root / 'proc/sys/kernel/random/boot_id'
boot.parent.mkdir(parents=True)
boot.write_text(BOOT)
self.script = (ROOT / 'src/orchard/usb-identity').read_text().replace(
'/proc/sys/kernel/random/boot_id', str(boot))

def serial(self):
return subprocess.run(['sh', '-ec', self.script], check=True, capture_output=True, text=True,
env={**os.environ, 'ORCHARD_DEVICE_TREE': str(self.tree)})

def test_hardware_serial_is_hashed_stable_across_boots_and_distinct_per_unit(self):
number = self.tree / 'serial-number'
number.write_bytes(b'HardwareSerialA\0')
first = self.serial()
expected = hashlib.sha256(b'orchard-HardwareSerialA').hexdigest()[:12]
self.assertEqual(first.stdout.strip(), f'BOARD:j71t ID:{expected}')
self.assertNotIn('HardwareSerialA', first.stdout + first.stderr)
(self.root / 'proc/sys/kernel/random/boot_id').write_text('22222222-2222-4222-8222-222222222222')
self.assertEqual(self.serial().stdout, first.stdout)
number.write_bytes(b'HardwareSerialB\0')
self.assertNotEqual(self.serial().stdout, first.stdout)

def test_missing_or_empty_serial_preserves_rescue_usb_with_a_warning(self):
for value in (None, b'', b'\0'):
if value is not None:
(self.tree / 'serial-number').write_bytes(value)
result = self.serial()
self.assertEqual(result.stdout.strip(), f'BOOT:{BOOT} BOARD:j71t')
self.assertIn('no device-tree serial-number', result.stderr)

def test_installed_and_ram_gadgets_use_the_same_stable_serial_before_binding(self):
(self.tree / 'serial-number').write_bytes(b'HardwareSerialA\0')
expected = self.serial().stdout.strip()
for relative in ('boot/initramfs/root/init', 'src/orchard/usb-session'):
with self.subTest(path=relative):
source = (ROOT / relative).read_text()
if relative.endswith('/init'):
start = source.index('usb_gadget() {')
source = source[start:source.index('\nusb_gadget ||', start)] + '\nusb_gadget'
else:
source = source[source.index('name=$(cat'):source.index('\nifconfig ')]
for directory in ('sys/kernel/config/usb_gadget', 'sys/class/udc/usbdev',
'newroot/etc', 'newroot/usr/lib', 'etc', 'usr/libexec/orchard'):
(self.root / directory).mkdir(parents=True, exist_ok=True)
(self.root / 'newroot/etc/os-release').write_text('NAME="Linux"\n')
(self.root / 'etc/hostname').write_text('ram-test\n')
for helper in ('etc/orchard/usb-identity', 'usr/libexec/orchard/usb-identity'):
path = self.root / helper
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self.script)
source = re.sub(r'/(sys|newroot|etc|usr)/', lambda m: str(self.root) + m.group(), source)
source = source.replace('mkdir "$g"', 'mkdir -p "$g/functions"')
subprocess.run(['sh', '-ec', 'mount() { :; };\n' + source], check=True, capture_output=True,
env={**os.environ, 'ORCHARD_DEVICE_TREE': str(self.tree)})
name = 'orchard-system' if relative.endswith('/init') else 'ram-test'
gadget = self.root / 'sys/kernel/config/usb_gadget' / name
self.assertEqual((gadget / 'strings/0x409/serialnumber').read_text().strip(), expected)
self.assertEqual((gadget / 'UDC').read_text().strip(), 'usbdev')
for function in ('ncm.usb0', 'acm.usb0'):
self.assertTrue((gadget / 'configs/c.1' / function).is_symlink())


if __name__ == '__main__':
unittest.main()
1 change: 1 addition & 0 deletions tests/test_platform_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_the_initrd_carries_busybox_and_the_storage_modules_only(self):
base = f'lib/modules/{RELEASE}/'
self.assertEqual(names['bin/busybox'], b'static busybox')
self.assertEqual(names['init'], (ROOT / 'boot/initramfs/root/init').read_bytes())
self.assertEqual(names['etc/orchard/usb-identity'], (ROOT / 'src/orchard/usb-identity').read_bytes())
self.assertEqual(names['etc/orchard/modules'], b'nvme\n')
self.assertEqual(sorted(name for name in names if name.endswith('.ko')),
[base + 'kernel/drivers/nvme/host/nvme-core.ko', base + 'kernel/drivers/nvme/host/nvme.ko'])
Expand Down
2 changes: 2 additions & 0 deletions tests/test_ram.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ def test_usb_session_is_added_unless_the_project_opts_out(self):
ram.build_directory(example, hardware=self.hardware)
archive = gzip.decompress((example / 'out/apple-16k/initramfs.cpio.gz').read_bytes())
self.assertIn(b'usr/libexec/orchard/usb-session\0', archive)
self.assertIn(b'usr/libexec/orchard/usb-identity\0', archive)
self.assertIn(b'etc/orchard/authorized_keys\0', archive)
public = (self.base / 'home/private/ram-ssh/id_ed25519.pub').read_bytes()
self.assertIn(public, archive)
Expand All @@ -484,6 +485,7 @@ def test_usb_session_is_added_unless_the_project_opts_out(self):
ram.build_directory(example, hardware=self.hardware)
archive = gzip.decompress((example / 'out/apple-16k/initramfs.cpio.gz').read_bytes())
self.assertNotIn(b'usr/libexec/orchard/usb-session\0', archive)
self.assertNotIn(b'usr/libexec/orchard/usb-identity\0', archive)
self.assertNotIn(b'etc/orchard/authorized_keys\0', archive)

def test_arbitrary_directory_layout_uses_active_hardware_package(self):
Expand Down
Loading
Loading