Prepare verified Sargo packages and independent boot recovery
This commit is contained in:
parent
d5cfc8a7d7
commit
978542bc87
14 changed files with 665 additions and 8 deletions
|
|
@ -14,6 +14,23 @@ import sys
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROJECT = ROOT.parent
|
||||
ANDROID_SHA = '132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886'
|
||||
HARDWARE_PACKAGES = {'systemd', 'phoc', 'libhybris', 'ofono',
|
||||
'ofono-binder-plugin', 'network-manager', 'pulseaudio'}
|
||||
|
||||
|
||||
def validate_reference(reference):
|
||||
manifest = json.loads((reference/'manifest.json').read_text())
|
||||
if manifest.get('architecture') != 'arm64':
|
||||
raise ValueError('Sargo requires an ARM64 runtime reference')
|
||||
packages = {name.split(':', 1)[0] for name in manifest.get('packages', {})}
|
||||
missing = HARDWARE_PACKAGES - packages
|
||||
if missing:
|
||||
raise ValueError('Incomplete hardware runtime; missing packages: '+', '.join(sorted(missing)))
|
||||
files = manifest.get('files', {})
|
||||
for name in ('/usr/sbin/ofonod', '/usr/sbin/NetworkManager', '/usr/bin/phoc'):
|
||||
if name not in files:
|
||||
raise ValueError('Missing hardware executable in runtime inventory: '+name)
|
||||
return manifest
|
||||
|
||||
|
||||
def digest(path):
|
||||
|
|
@ -80,15 +97,18 @@ def pack(root, image):
|
|||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--reference', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference-20260925')
|
||||
parser.add_argument('--reference', type=Path, help='Explicit ARM64 hardware runtime reference')
|
||||
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
|
||||
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.pack:
|
||||
pack(*args.pack)
|
||||
return
|
||||
if args.reference is None:
|
||||
parser.error('--reference is required; display-only runtime references are not sufficient')
|
||||
if os.geteuid() == 0:
|
||||
parser.error('Build as a regular user')
|
||||
validate_reference(args.reference)
|
||||
android = ROOT/'downloads/sargo-installed/android-rootfs.img'
|
||||
if digest(android) != ANDROID_SHA:
|
||||
raise ValueError('Android HAL reference changed')
|
||||
|
|
|
|||
65
tundra/tools/export-sargo-recovery.py
Normal file
65
tundra/tools/export-sargo-recovery.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Export the qualified original boot backup and a standalone restoration tool.
|
||||
|
||||
The kit is private and specific to one phone. Keep it outside the project and
|
||||
do not upload it as a release. Exporting performs no phone operations.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from tundra import ROOT, digest
|
||||
|
||||
|
||||
def load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def export(plan, output):
|
||||
output = Path(output).absolute()
|
||||
if output.exists() or output.is_symlink():
|
||||
raise ValueError('Recovery output exists; keep the existing backup')
|
||||
backup = Path(plan['backupDirectory'])
|
||||
from boot_backup import verify
|
||||
manifest = verify(backup)
|
||||
if digest(backup/'manifest.json') != plan['backupManifestSha256']:
|
||||
raise ValueError('Backup manifest changed')
|
||||
output.mkdir(mode=0o700)
|
||||
marker = output/'.incomplete'
|
||||
marker.write_text('Recovery export incomplete\n')
|
||||
shutil.copyfile(backup/'boot_a.img', output/'boot_a.img')
|
||||
(output/'boot_a.img').chmod(0o600)
|
||||
tool = ROOT/'tools/restore-sargo.py'
|
||||
shutil.copyfile(tool, output/tool.name)
|
||||
(output/tool.name).chmod(0o700)
|
||||
recovery = {'schema': 1, 'device': 'sargo', 'partition': 'boot_a', 'slot': 'a',
|
||||
'serialSha256': plan['serialSha256'], 'image': 'boot_a.img',
|
||||
'bytes': manifest['files']['boot_a.img']['bytes'],
|
||||
'imageSha256': manifest['files']['boot_a.img']['sha256'],
|
||||
'backupManifestSha256': plan['backupManifestSha256'],
|
||||
'restoreToolSha256': digest(tool), 'private': True,
|
||||
'scope': 'Original qualified boot_a only; not a userdata backup or a generic ROM'}
|
||||
(output/'recovery.json').write_text(json.dumps(recovery, indent=2)+'\n')
|
||||
load('restore_check', tool).checked_kit(output)
|
||||
marker.unlink()
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
plan = load('backup_plan', ROOT/'tools/test-sargo-boot.py').checked_backup_plan()
|
||||
print(export(plan, args.output))
|
||||
print('Keep this private kit on another disk. Its boot image belongs only to this phone.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -58,12 +58,13 @@ def main():
|
|||
action.add_argument('--restore',action='store_true',help='Restore the verified original boot_a backup; retain Tundra data')
|
||||
args=parser.parse_args();os.umask(0o077)
|
||||
boot=load('sargo_boot',ROOT/'tools/test-sargo-boot.py')
|
||||
plan,_=boot.checked_plan()
|
||||
if args.restore:
|
||||
# Recovery must still work if the candidate or its proof was damaged.
|
||||
plan=boot.checked_backup_plan()
|
||||
image=Path(plan['backupDirectory'])/'boot_a.img'
|
||||
build={'serialSha256':plan['serialSha256']}
|
||||
else:
|
||||
plan,_=boot.checked_plan()
|
||||
build,qualification,image=checked_build(args.build)
|
||||
if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone')
|
||||
print('Private Sargo installation plan')
|
||||
|
|
|
|||
184
tundra/tools/prepare-sargo-package.py
Normal file
184
tundra/tools/prepare-sargo-package.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Verify a Sargo system archive and prepare fresh local installation files.
|
||||
|
||||
This tool never connects to a phone. The result is a userspace image and a new
|
||||
data image, not a flashable ROM. A device-specific boot/recovery qualification
|
||||
is still required. Get the manifest checksum through a trusted release channel.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from persistent_state import digest, validate_manifest
|
||||
|
||||
CHUNK = 1024 * 1024
|
||||
TOOLS = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def sha256(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r'[a-f0-9]{64}', value):
|
||||
raise ValueError('Expected a SHA256 checksum')
|
||||
return value
|
||||
|
||||
|
||||
def checked_manifest(path, expected):
|
||||
path = Path(path)
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > CHUNK:
|
||||
raise ValueError('Expected a regular package manifest smaller than 1 MiB')
|
||||
data = path.read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != sha256(expected):
|
||||
raise ValueError('Package manifest checksum mismatch')
|
||||
package = json.loads(data)
|
||||
if package.get('device') != 'sargo':
|
||||
raise ValueError('Only Sargo system packages are supported')
|
||||
name = package.get('systemImage', '')
|
||||
if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*\.ext4\.xz', name):
|
||||
raise ValueError('Archive must be a plain .ext4.xz filename')
|
||||
if not isinstance(package.get('version'), str) or not package['version'].strip():
|
||||
raise ValueError('Missing package version')
|
||||
for key in ('archiveSha256', 'systemImageSha256'):
|
||||
sha256(package.get(key))
|
||||
for key, maximum in (('archiveBytes', 2 * 1024**3), ('uncompressedBytes', 8 * 1024**3)):
|
||||
size = package.get(key)
|
||||
if type(size) is not int or not 0 < size <= maximum:
|
||||
raise ValueError('Invalid '+key)
|
||||
if package['uncompressedBytes'] % 4096:
|
||||
raise ValueError('System image size must be aligned to 4096 bytes')
|
||||
for key in ('filesystemCheckPassed', 'compressionCheckPassed'):
|
||||
if package.get(key) is not True:
|
||||
raise ValueError('Package has not passed '+key)
|
||||
return package
|
||||
|
||||
|
||||
def checked_archive(folder, package):
|
||||
archive = Path(folder)/package['systemImage']
|
||||
if archive.is_symlink() or not archive.is_file():
|
||||
raise ValueError('Expected a regular archive beside the manifest')
|
||||
if archive.stat().st_size != package['archiveBytes'] or digest(archive) != package['archiveSha256']:
|
||||
raise ValueError('System archive checksum or size mismatch')
|
||||
return archive
|
||||
|
||||
|
||||
def unpack(archive, image, package):
|
||||
"""Bound both memory and output; reject trailing data and truncated streams."""
|
||||
decoder = lzma.LZMADecompressor(format=lzma.FORMAT_XZ, memlimit=128 * CHUNK)
|
||||
compressed = hashlib.sha256()
|
||||
expanded = hashlib.sha256()
|
||||
total = count = 0
|
||||
created = False
|
||||
try:
|
||||
with Path(archive).open('rb') as source, Path(image).open('xb') as target:
|
||||
created = True
|
||||
while chunk := source.read(CHUNK):
|
||||
if decoder.eof:
|
||||
raise ValueError('Trailing data after XZ stream')
|
||||
compressed.update(chunk)
|
||||
count += len(chunk)
|
||||
if count > package['archiveBytes']:
|
||||
raise ValueError('Archive exceeds declared size')
|
||||
while True:
|
||||
block = decoder.decompress(chunk, max_length=CHUNK)
|
||||
chunk = b''
|
||||
total += len(block)
|
||||
if total > package['uncompressedBytes']:
|
||||
raise ValueError('System image exceeds declared size')
|
||||
expanded.update(block)
|
||||
target.write(block)
|
||||
if decoder.eof or decoder.needs_input:
|
||||
break
|
||||
if decoder.unused_data:
|
||||
raise ValueError('Trailing data after XZ stream')
|
||||
if not decoder.eof:
|
||||
raise ValueError('Truncated XZ stream')
|
||||
if (count != package['archiveBytes'] or total != package['uncompressedBytes']
|
||||
or compressed.hexdigest() != package['archiveSha256']
|
||||
or expanded.hexdigest() != package['systemImageSha256']):
|
||||
raise ValueError('Expanded image or archive checksum mismatch')
|
||||
target.flush()
|
||||
os.fsync(target.fileno())
|
||||
except BaseException:
|
||||
if created:
|
||||
Path(image).unlink()
|
||||
raise
|
||||
|
||||
|
||||
def prepare(manifest, expected, output, size_mib):
|
||||
if type(size_mib) is not int or not 512 <= size_mib <= 8192:
|
||||
raise ValueError('Choose 512–8192 MiB for fresh data')
|
||||
package = checked_manifest(manifest, expected)
|
||||
archive = checked_archive(Path(manifest).parent, package)
|
||||
output = Path(output).absolute()
|
||||
if output.exists() or output.is_symlink():
|
||||
raise ValueError('Output already exists; existing installation data must not be overwritten')
|
||||
if not output.parent.is_dir():
|
||||
raise ValueError('Create the output parent directory first')
|
||||
for tool in ('e2fsck', 'mke2fs', 'debugfs'):
|
||||
if not shutil.which(tool):
|
||||
raise ValueError('Missing required program: '+tool)
|
||||
required = package['uncompressedBytes'] + size_mib * CHUNK + 256 * CHUNK
|
||||
if shutil.disk_usage(output.parent).free < required:
|
||||
raise ValueError('Insufficient local disk space for system, data and reserve')
|
||||
output.mkdir(mode=0o700)
|
||||
marker = output/'.incomplete'
|
||||
marker.write_text('Preparation did not finish. Do not use these files.\n')
|
||||
image = output/'tundra-sargo-system.ext4'
|
||||
unpack(archive, image, package)
|
||||
check = subprocess.run(['e2fsck', '-f', '-n', str(image)],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
(output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
|
||||
if check.returncode:
|
||||
raise ValueError('System filesystem check failed; no data image created')
|
||||
system = {'schema': 1, 'device': 'sargo', 'image': image.name,
|
||||
'imageSha256': package['systemImageSha256'], 'bytes': image.stat().st_size,
|
||||
'filesystemCheckPassed': True, 'flashReady': False,
|
||||
'packageVersion': package['version'], 'packageManifestSha256': expected}
|
||||
system_path = output/'system.json'
|
||||
system_path.write_text(json.dumps(system, indent=2)+'\n')
|
||||
subprocess.run([sys.executable, str(TOOLS/'build-state-image.py'), '--output', str(output/'data'),
|
||||
'--system-image', str(system_path), '--size-mib', str(size_mib)],
|
||||
check=True, timeout=300)
|
||||
state = validate_manifest(output/'data/state.json')
|
||||
if state['systemImageSha256'] != system['imageSha256']:
|
||||
raise ValueError('Data image belongs to another system image')
|
||||
report = {'schema': 1, 'device': 'sargo', 'version': package['version'],
|
||||
'packageManifestSha256': expected, 'systemImageSha256': system['imageSha256'],
|
||||
'stateUuid': state['uuid'], 'localPreparationPassed': True,
|
||||
'phoneTouched': False, 'flashReady': False,
|
||||
'remaining': ['Per-phone storage and boot backup inventory',
|
||||
'Device-specific boot image and recovery qualification',
|
||||
'Transfer, two RAM boots and persistence check before installation'],
|
||||
'supportedStorage': 'Sargo userdata with an inventoried single linear ext4 extent; not stock Android/F2FS'}
|
||||
(output/'preparation.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||
marker.unlink()
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest', required=True, type=Path)
|
||||
parser.add_argument('--manifest-sha256', required=True)
|
||||
parser.add_argument('--prepare', type=Path, metavar='NEW_DIRECTORY',
|
||||
help='Expand and check the system; create separate fresh data locally')
|
||||
parser.add_argument('--data-mib', type=int, default=1024)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
if args.prepare:
|
||||
report = prepare(args.manifest, args.manifest_sha256, args.prepare, args.data_mib)
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
package = checked_manifest(args.manifest, args.manifest_sha256)
|
||||
checked_archive(args.manifest.parent, package)
|
||||
print('Manifest and compressed archive verified. No extraction or phone operation performed.')
|
||||
print('Not a flashable ROM. Never fastboot flash this ext4 file to userdata.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
109
tundra/tools/restore-sargo.py
Normal file
109
tundra/tools/restore-sargo.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Restore boot_a from a private recovery kit. Runs without the Vulpes checkout.
|
||||
|
||||
By default this only verifies local files. --restore writes the saved boot_a
|
||||
to the matching unlocked Sargo on slot A. It never erases userdata, changes
|
||||
slots or reboots automatically. This does not restore or back up user data.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
BOOT_SIZE = 67108864
|
||||
|
||||
|
||||
def digest(path):
|
||||
with Path(path).open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def checked_kit(folder):
|
||||
folder = Path(folder)
|
||||
path = folder/'recovery.json'
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536:
|
||||
raise ValueError('Expected a regular recovery manifest')
|
||||
manifest = json.loads(path.read_text())
|
||||
if (manifest.get('schema') != 1 or manifest.get('device') != 'sargo'
|
||||
or manifest.get('partition') != 'boot_a' or manifest.get('slot') != 'a'
|
||||
or manifest.get('image') != 'boot_a.img' or manifest.get('bytes') != BOOT_SIZE):
|
||||
raise ValueError('Expected a Sargo slot A recovery kit')
|
||||
for key in ('serialSha256', 'imageSha256', 'backupManifestSha256'):
|
||||
if not re.fullmatch(r'[a-f0-9]{64}', str(manifest.get(key, ''))):
|
||||
raise ValueError('Missing recovery checksum: '+key)
|
||||
image = folder/'boot_a.img'
|
||||
if image.is_symlink() or not image.is_file() or image.stat().st_size != BOOT_SIZE:
|
||||
raise ValueError('Saved boot_a is missing or has the wrong size')
|
||||
if digest(image) != manifest['imageSha256']:
|
||||
raise ValueError('Saved boot_a checksum mismatch')
|
||||
with image.open('rb') as stream:
|
||||
if stream.read(8) != b'ANDROID!':
|
||||
raise ValueError('Saved boot_a has no Android boot header')
|
||||
return manifest, image
|
||||
|
||||
|
||||
def getvar(serial, name):
|
||||
result = subprocess.run(['fastboot', '-s', serial, 'getvar', name],
|
||||
capture_output=True, text=True, check=True, timeout=15)
|
||||
for line in (result.stdout+'\n'+result.stderr).splitlines():
|
||||
match = re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)', line.strip())
|
||||
if match:
|
||||
return match[1].strip()
|
||||
raise ValueError('Fastboot did not return '+name)
|
||||
|
||||
|
||||
def checked_device(manifest):
|
||||
output = subprocess.check_output(['fastboot', 'devices'], text=True, timeout=10)
|
||||
matches = []
|
||||
for line in output.splitlines():
|
||||
fields = line.split()
|
||||
if (len(fields) == 2 and fields[1] == 'fastboot'
|
||||
and hashlib.sha256(fields[0].encode()).hexdigest() == manifest['serialSha256']):
|
||||
matches.append(fields[0])
|
||||
if len(matches) != 1:
|
||||
raise ValueError('The phone belonging to this backup is not uniquely present in Fastboot')
|
||||
serial = matches[0]
|
||||
values = {name: getvar(serial, name) for name in
|
||||
('product', 'unlocked', 'current-slot', 'partition-size:boot_a')}
|
||||
if (values['product'] != 'sargo' or values['unlocked'] != 'yes'
|
||||
or values['current-slot'] != 'a'
|
||||
or int(values['partition-size:boot_a'], 16) != BOOT_SIZE):
|
||||
raise ValueError('Expected the matching unlocked Sargo on slot A with a 64 MiB boot partition')
|
||||
return serial
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--kit', type=Path, default=Path(__file__).resolve().parent)
|
||||
parser.add_argument('--restore', action='store_true')
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
if (args.kit/'.incomplete').exists():
|
||||
raise ValueError('Recovery kit export did not finish')
|
||||
manifest, image = checked_kit(args.kit)
|
||||
print('Saved boot_a verified:', manifest['imageSha256'])
|
||||
if not args.restore:
|
||||
print('Local verification only. No phone command sent.')
|
||||
return
|
||||
serial = checked_device(manifest)
|
||||
# Verify again after connecting, before allowing the only write command.
|
||||
current, image = checked_kit(args.kit)
|
||||
if current != manifest:
|
||||
raise ValueError('Recovery kit changed during device verification')
|
||||
report = {'operation': 'restore', 'partition': 'boot_a',
|
||||
'imageSha256': manifest['imageSha256'], 'completed': False}
|
||||
path = args.kit/'restore-report.json'
|
||||
path.write_text(json.dumps(report, indent=2)+'\n')
|
||||
subprocess.run(['fastboot', '-s', serial, 'flash', 'boot_a', str(image.resolve())],
|
||||
check=True, timeout=90)
|
||||
report['completed'] = True
|
||||
path.write_text(json.dumps(report, indent=2)+'\n')
|
||||
print('Saved boot_a restored. Select Start on the phone. No userdata erase or slot change sent.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -17,7 +17,8 @@ from tundra import ROOT, digest, now, save
|
|||
from usb_protocol import exchange, device_layout
|
||||
|
||||
|
||||
def checked_plan():
|
||||
def checked_backup_plan():
|
||||
"""Recovery only needs the qualified backup, not a working diagnostic."""
|
||||
path = ROOT/'logs/device/boot-backup-qualification.json'
|
||||
validation = json.loads((ROOT/'VALIDATION.json').read_text())
|
||||
if validation.get('bootBackup', {}).get('reportSha256') != digest(path):
|
||||
|
|
@ -27,6 +28,14 @@ def checked_plan():
|
|||
verify(backup)
|
||||
if digest(backup/'manifest.json') != plan['backupManifestSha256']:
|
||||
raise ValueError('Backup manifest changed; requalify')
|
||||
if (plan.get('device') != 'sargo' or plan.get('activeSlot') != 'a'
|
||||
or not re.fullmatch(r'[0-9a-f]{64}', plan.get('serialSha256', ''))):
|
||||
raise ValueError('Backup qualification must identify a Sargo on slot A')
|
||||
return plan
|
||||
|
||||
|
||||
def checked_plan():
|
||||
plan = checked_backup_plan()
|
||||
image = ROOT/plan['diagnosticImage']
|
||||
if digest(image) != plan['diagnosticImageSha256'] or digest(image.parent/'build.json') != plan['diagnosticBuildSha256']:
|
||||
raise ValueError('Diagnostic image/build changed; requalify')
|
||||
|
|
|
|||
Loading…
Reference in a new issue