386 lines
21 KiB
Python
386 lines
21 KiB
Python
"""Fixed privileged D09 OS stage. No CLI, shell, repair loop, or unsigned mode.
|
|
|
|
The protected D09 binding is the authority input. APT owns Debian maintainer
|
|
script execution; those scripts are explicitly reviewed root authority.
|
|
"""
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import platform
|
|
import re
|
|
import subprocess
|
|
import tarfile
|
|
|
|
from d09_store import atomic, read, secure, sync_dir
|
|
from os_update_plan import canonical, digest, hashed, keys, protected, require, simulation, validate
|
|
from os_update_repository import KEYRING, acquire, command
|
|
from os_update_lock import PackageLease
|
|
import os_update_custody as custody
|
|
|
|
ROOT = pathlib.Path('/var/lib/archipelago-os-update')
|
|
VERIFIER = pathlib.Path('/usr/libexec/archipelago-os-release-verifier')
|
|
INVENTORY = '${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Abbrev}\n'
|
|
|
|
|
|
def inventory():
|
|
return command(['/usr/bin/dpkg-query', '-W', '-f=' + INVENTORY]).encode()
|
|
|
|
|
|
def boot_id():
|
|
return pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
|
|
|
|
|
def file_hash(path):
|
|
import hashlib
|
|
with open(path, 'rb') as source:
|
|
return hashlib.file_digest(source, 'sha256').hexdigest()
|
|
|
|
|
|
class ReconciliationRequired(RuntimeError):
|
|
pass
|
|
|
|
|
|
class Executor:
|
|
def __init__(self, binding, create=True):
|
|
keys(binding, 'version plan_sha256 manifest_sha256 authority_digest verifier_sha256')
|
|
require(type(binding['version']) is int and binding['version'] == 1, 'OS binding version')
|
|
for field in ('plan_sha256', 'manifest_sha256', 'authority_digest', 'verifier_sha256'):
|
|
hashed(binding[field])
|
|
custody.refuse_legacy()
|
|
self._pins = [custody.Pin(ROOT)]
|
|
self.binding = binding
|
|
self.source = ROOT / 'plans' / binding['plan_sha256']
|
|
self._pins.append(custody.Pin(self.source))
|
|
raw = read(self.source / 'plan.json')
|
|
require(digest(raw) == binding['plan_sha256'], 'release plan hash')
|
|
self.plan = validate(json.loads(raw))
|
|
raw = read(self.source / 'manifest.json')
|
|
require(digest(raw) == binding['manifest_sha256'], 'release manifest hash')
|
|
manifest = json.loads(raw)
|
|
require(manifest.get('os_update') == dict(version=1, plan_sha256=binding['plan_sha256']), 'manifest plan binding')
|
|
require(digest(read(VERIFIER)) == binding['verifier_sha256'], 'appointed verifier changed')
|
|
command([str(VERIFIER), 'ceremony', 'verify', str(self.source / 'manifest.json')])
|
|
require(digest(read(self.source / 'preservation.json')) == self.plan['preservation_sha256'], 'preservation authority')
|
|
preservation = json.loads(read(self.source / 'preservation.json'))
|
|
require(preservation.get('authority_digest') == binding['authority_digest'] and
|
|
preservation.get('service_policy') == self.plan['service_policy'] and
|
|
preservation.get('preserve_wallets_data_identity_stop_uninstall') is True,
|
|
'external maintenance/preservation/writer exclusion authority required')
|
|
self.root = ROOT / 'transactions' / binding['authority_digest']
|
|
self._pins.append(custody.Pin(self.root.parent))
|
|
self.status_path = self.root / 'status.json'
|
|
initial = dict(
|
|
version=1, plan_sha256=binding['plan_sha256'], authority_digest=binding['authority_digest'],
|
|
phase=None, sequence=0, events=[])
|
|
pin, self.status = custody.transaction(self.root, initial, create)
|
|
if pin is not None:
|
|
self._pins.append(pin)
|
|
require(self.status['plan_sha256'] == binding['plan_sha256'] and
|
|
self.status['authority_digest'] == binding['authority_digest'], 'authority already bound')
|
|
|
|
def check_custody(self):
|
|
custody.refuse_legacy()
|
|
for pin in getattr(self, '_pins', ()):
|
|
pin.check()
|
|
if hasattr(self, '_pins') and self.root.exists():
|
|
require(digest(read(self.status_path)) == digest(canonical(self.status)),
|
|
'OS journal changed or removed; reconciliation required')
|
|
|
|
def close(self):
|
|
for pin in getattr(self, '_pins', ()):
|
|
pin.close()
|
|
|
|
def journal(self, phase, **details):
|
|
self.check_custody()
|
|
self.status['phase'] = phase
|
|
self.status['sequence'] += 1
|
|
self.status['events'].append(dict(phase=phase, details=details))
|
|
atomic(self.status_path, canonical(self.status))
|
|
checkpoint = getattr(self, 'checkpoint', None)
|
|
if checkpoint is not None:
|
|
checkpoint() # Live D09 owner anchors intent before any dpkg effects.
|
|
|
|
def apt(self, *args):
|
|
self.check_custody()
|
|
# APT_CONFIG is read before apt.conf.d. Both host configuration entrypoints
|
|
# are disabled; no inherited hooks, sources, proxy, shell or apt arguments.
|
|
env = dict(PATH='/usr/sbin:/usr/bin:/sbin:/bin', LC_ALL='C',
|
|
APT_CONFIG=str(self.root / 'apt.conf'), DEBIAN_FRONTEND='noninteractive',
|
|
NEEDRESTART_MODE='l')
|
|
result = subprocess.run(['/usr/bin/apt-get', *args], env=env, text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
atomic(self.root / ('apt-%016d-%s.log' % (self.status['sequence'], os.urandom(8).hex())),
|
|
(result.stdout + '\n' + result.stderr).encode())
|
|
require(result.returncode == 0, 'APT failed; inspect retained apt log')
|
|
return result.stdout
|
|
|
|
def configure(self):
|
|
self.check_custody()
|
|
for name in ('lists', 'archives', 'empty', 'lists/partial', 'archives/partial'):
|
|
pin, _ = custody.mkdir(self.root / name)
|
|
pin.close()
|
|
sources = []
|
|
for repo in self.plan['repositories']:
|
|
components = sorted({x['path'].split('/')[0] for x in repo['indexes']})
|
|
sources.append('deb [signed-by=' + KEYRING + '] file:' + str(self.root / 'mirror' / repo['pocket']) + ' ' + repo['pocket'] + ' ' + ' '.join(components))
|
|
atomic(self.root / 'sources.list', ('\n'.join(sources) + '\n').encode())
|
|
config = {
|
|
'Dir::Etc::main': '-', 'Dir::Etc::parts': str(self.root / 'empty'),
|
|
'Dir::Etc::sourcelist': str(self.root / 'sources.list'),
|
|
'Dir::Etc::sourceparts': str(self.root / 'empty'),
|
|
'Dir::Etc::preferences': '-', 'Dir::Etc::preferencesparts': str(self.root / 'empty'),
|
|
'Dir::Etc::trusted': '-', 'Dir::Etc::trustedparts': str(self.root / 'empty'),
|
|
'Dir::State::lists': str(self.root / 'lists'),
|
|
'Dir::Cache::archives': str(self.root / 'archives'),
|
|
'Dir::Cache::pkgcache': '', 'Dir::Cache::srcpkgcache': '',
|
|
'APT::Architecture': self.plan['architecture'],
|
|
'APT::Sandbox::User': 'root', 'APT::Get::AllowUnauthenticated': 'false',
|
|
'Acquire::AllowInsecureRepositories': 'false',
|
|
'Acquire::AllowDowngradeToInsecureRepositories': 'false',
|
|
'Acquire::Check-Valid-Until': 'true', 'Acquire::Check-Date': 'true',
|
|
'Acquire::Languages': 'none',
|
|
'APT::Get::AutomaticRemove': 'false',
|
|
}
|
|
text = '\n'.join(key + ' ' + json.dumps(value) + ';' for key, value in config.items())
|
|
text += '\nDPkg::Options { "--force-confold"; };\n'
|
|
atomic(self.root / 'apt.conf', text.encode())
|
|
|
|
def preflight(self):
|
|
self.check_custody()
|
|
policy = self.plan.get('manager_reexec')
|
|
if policy and not any(p['name'] == 'systemd' for p in self.plan['packages']):
|
|
actual = command(['/usr/bin/dpkg-query', '-W', '-f=${Version}\t${Architecture}', 'systemd'])
|
|
require(actual == policy['package_version'] + '\t' + policy['architecture'],
|
|
'manager reexec package differs from installed reviewed version')
|
|
require(file_hash('/proc/1/exe') == policy['expected_executable_sha256'],
|
|
'unchanged manager executable differs from signed plan')
|
|
require(command(['/usr/bin/dpkg', '--print-architecture']).strip() == self.plan['architecture'], 'host architecture')
|
|
require(not command(['/usr/bin/dpkg', '--print-foreign-architectures']).strip(), 'multiarch full-suite planning requires a separate contract')
|
|
osrelease = dict(line.split('=', 1) for line in pathlib.Path('/etc/os-release').read_text().splitlines() if '=' in line)
|
|
require(osrelease.get('ID', '').strip('"') == 'debian' and osrelease.get('VERSION_CODENAME', '').strip('"') == self.plan['suite'], 'silent distribution upgrade refused')
|
|
current = inventory()
|
|
require(digest(current) == self.plan['before_sha256'], 'dpkg inventory changed')
|
|
require(not command(['/usr/bin/dpkg', '--audit']).strip(), 'partial dpkg state requires reconciliation')
|
|
installed = set()
|
|
for line in current.decode().splitlines():
|
|
name, version, arch, status = line.split('\t')
|
|
require(status.startswith(('ii ', 'hi ', 'rc ')), 'unresolved dpkg state')
|
|
if status.startswith(('ii ', 'hi ')):
|
|
installed.add(name.split(':')[0])
|
|
kernel = self.plan['boot']['package']
|
|
if kernel not in self.plan['packages']:
|
|
identity = command(['/usr/bin/dpkg-query', '-W', '-f=${Version}\t${Architecture}\t${db:Status-Abbrev}', kernel['name']])
|
|
require(identity == kernel['version'] + '\t' + kernel['architecture'] + '\tii ', 'retained target kernel not configured')
|
|
changes = {x['name'].split(':')[0] for x in self.plan['transaction'] if x['operation'] in ('Inst', 'Remv')}
|
|
custody = {x['name']: x['disposition'] for x in self.plan['custody']}
|
|
for name in installed | changes:
|
|
if protected(name):
|
|
require(custody.get(name) == ('upgrade-reviewed' if name in changes else 'retain'), 'unreviewed custody disposition: ' + name)
|
|
if re.match(r'linux-image-[0-9]', name) and name in installed:
|
|
require(name not in changes, 'installed kernel must remain intact')
|
|
require(platform.release() == self.plan['boot']['running_release'], 'running kernel changed')
|
|
for path, sha in self.plan['boot']['retained_files'].items():
|
|
require(file_hash(path) == sha, 'old boot provenance changed')
|
|
|
|
def solver(self):
|
|
actual = simulation(self.apt('--simulate', 'dist-upgrade'))
|
|
require(actual == self.plan['transaction'], 'whole dependency transaction differs from release plan')
|
|
installs = {(x['name'].split(':')[0], x['version']) for x in actual if x['operation'] == 'Inst'}
|
|
require(installs == {(x['name'], x['version']) for x in self.plan['packages']}, 'incomplete archive transaction')
|
|
|
|
def prepare(self):
|
|
self.check_custody()
|
|
if self.status['phase'] in ('VERIFIED', 'REBOOT_REQUIRED', 'POSTBOOT_CONFIRMED'):
|
|
return self.status
|
|
if self.status['phase'] is not None:
|
|
raise ReconciliationRequired('interrupted preparation/apply requires external reconciliation')
|
|
self.journal('PREPARE', boot_id=boot_id())
|
|
self.preflight()
|
|
atomic(self.root / 'before-inventory', inventory())
|
|
self.journal('DOWNLOAD')
|
|
acquire(self.plan, self.root / 'mirror')
|
|
self.configure()
|
|
self.apt('update')
|
|
self.solver()
|
|
self.apt('--download-only', '--assume-yes', 'dist-upgrade')
|
|
# APT's file method may use the mirror directly instead of populating
|
|
# archives. Keep independent, durable bytes under its canonical cache name.
|
|
for package in self.plan['packages']:
|
|
name = package['name'] + '_' + package['version'].replace(':', '%3a') + '_' + package['architecture'] + '.deb'
|
|
raw = read(self.root / 'mirror' / package['pocket'] / package['filename'])
|
|
require(digest(raw) == package['sha256'], 'archive changed during preparation')
|
|
atomic(self.root / 'archives' / name, raw)
|
|
self.verify_archives()
|
|
# Durable retained boot preimages are recovery evidence, not a DB rollback.
|
|
for path, sha in self.plan['boot']['retained_files'].items():
|
|
atomic(self.root / ('retained-' + digest(path.encode())), pathlib.Path(path).read_bytes())
|
|
for directory, dirs, files in os.walk(self.root, topdown=False):
|
|
for name in files:
|
|
fd = secure(pathlib.Path(directory) / name)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
sync_dir(directory)
|
|
self.journal('VERIFIED')
|
|
return self.status
|
|
|
|
def verify_archives(self):
|
|
expected = {p['sha256'] for p in self.plan['packages']}
|
|
actual = {file_hash(p) for p in (self.root / 'archives').glob('*.deb')}
|
|
require(actual == expected, 'offline archive set mismatch')
|
|
|
|
def apply(self):
|
|
self.check_custody()
|
|
if self.status['phase'] in ('REBOOT_REQUIRED', 'POSTBOOT_CONFIRMED'):
|
|
return self.status
|
|
if self.status['phase'] != 'VERIFIED':
|
|
raise ReconciliationRequired('OS authority consumed or preparation incomplete; never retry dpkg')
|
|
# Re-evaluate signed metadata dates even if VERIFIED sat pending for days.
|
|
self.apt('update')
|
|
with PackageLease(self.root / 'apt.conf') as lease:
|
|
self.preflight()
|
|
self.verify_archives()
|
|
self.solver()
|
|
lease.plan(self.plan['transaction'])
|
|
self.journal('APPLY_INTENT', boot_id=boot_id(), package_locks=lease.observation)
|
|
try:
|
|
self.check_custody()
|
|
lease.install()
|
|
self.journal('VERIFY')
|
|
self.verify_installed()
|
|
boot = self.boot_provenance()
|
|
self.journal('REBOOT_REQUIRED', boot_id=boot_id(), boot=boot,
|
|
inventory_sha256=digest(inventory()))
|
|
except BaseException as exc:
|
|
if self.status['phase'] not in ('REBOOT_REQUIRED', 'POSTBOOT_CONFIRMED'):
|
|
self.journal('RECONCILIATION_REQUIRED', error=type(exc).__name__)
|
|
raise
|
|
return self.status
|
|
|
|
def verify_installed(self):
|
|
require(not command(['/usr/bin/dpkg', '--audit']).strip(), 'dpkg remains partial')
|
|
rows = {}
|
|
for line in inventory().decode().splitlines():
|
|
name, version, arch, status = line.split('\t')
|
|
rows[(name.split(':')[0], arch)] = (version, status)
|
|
for package in self.plan['packages']:
|
|
require(rows.get((package['name'], package['architecture'])) == (package['version'], 'ii '), 'installed package mismatch')
|
|
for name in self.plan['removals']:
|
|
require(not any(n == name.split(':')[0] and status.startswith(('ii', 'hi')) for (n, a), (v, status) in rows.items()), 'removal incomplete')
|
|
expected = {}
|
|
for line in read(self.root / 'before-inventory').decode().splitlines():
|
|
name, version, arch, status = line.split('\t')
|
|
if status.startswith(('ii ', 'hi ')):
|
|
expected[(name.split(':')[0], arch)] = version
|
|
for name in self.plan['removals']:
|
|
for identity in list(expected):
|
|
if identity[0] == name.split(':')[0]:
|
|
del expected[identity]
|
|
for package in self.plan['packages']:
|
|
expected[(package['name'], package['architecture'])] = package['version']
|
|
actual = {identity: version for identity, (version, status) in rows.items() if status.startswith(('ii ', 'hi '))}
|
|
require(actual == expected, 'unexpected whole-system package drift')
|
|
for path, sha in self.plan['boot']['retained_files'].items():
|
|
if path != '/boot/grub/grub.cfg':
|
|
require(file_hash(path) == sha, 'old boot image lost')
|
|
|
|
def boot_provenance(self):
|
|
release = self.plan['boot']['expected_release']
|
|
package = self.plan['boot']['package']
|
|
identity = command(['/usr/bin/dpkg-query', '-W', '-f=${Version}\t${Architecture}\t${db:Status-Abbrev}', package['name']])
|
|
require(identity == package['version'] + '\t' + package['architecture'] + '\tii ', 'expected kernel package not configured')
|
|
archive = self.root / 'mirror' / package['pocket'] / package['filename']
|
|
require(file_hash(archive) == package['sha256'], 'kernel archive changed')
|
|
hashes = {}
|
|
process = subprocess.Popen(['/usr/bin/dpkg-deb', '--fsys-tarfile', str(archive)], stdout=subprocess.PIPE)
|
|
try:
|
|
with tarfile.open(fileobj=process.stdout, mode='r|') as tar:
|
|
for member in tar:
|
|
if member.isfile():
|
|
path = '/' + member.name.removeprefix('./')
|
|
if path.startswith(('/boot/', '/lib/modules/', '/usr/lib/modules/')):
|
|
sha = digest(tar.extractfile(member).read())
|
|
require(file_hash(path) == sha, 'kernel payload provenance mismatch')
|
|
hashes[path] = sha
|
|
require(process.wait() == 0, 'kernel archive read failed')
|
|
finally:
|
|
process.stdout.close()
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait()
|
|
require('/boot/vmlinuz-' + release in hashes and any('/modules/' in x for x in hashes), 'kernel payload incomplete')
|
|
for path in ('/boot/initrd.img-' + release, '/boot/grub/grub.cfg'):
|
|
hashes[path] = file_hash(path)
|
|
for path in pathlib.Path('/boot').rglob('*'):
|
|
if path.is_file() and not path.is_symlink() and path.suffix.lower() == '.efi':
|
|
hashes[str(path)] = file_hash(path)
|
|
return dict(release=release, package_version=package['version'],
|
|
archive_sha256=package['sha256'], files=hashes)
|
|
|
|
def confirm_postboot(self):
|
|
self.check_custody()
|
|
require(self.status['phase'] == 'REBOOT_REQUIRED', 'no pending OS reboot')
|
|
observation = self.observe_postboot()
|
|
self.journal('POSTBOOT_CONFIRMED', boot_id=boot_id(), observation_sha256=digest(canonical(observation)))
|
|
return self.status
|
|
|
|
def observe_postboot(self):
|
|
self.check_custody()
|
|
event = next(e['details'] for e in reversed(self.status['events']) if e['phase'] == 'REBOOT_REQUIRED')
|
|
require(boot_id() != event['boot_id'] and platform.release() == event['boot']['release'], 'expected new boot not observed')
|
|
require(re.search(r'\bDebian ' + re.escape(event['boot']['package_version']) + r'(?:\s|$)', platform.version()),
|
|
'running Debian build version differs from authenticated kernel archive')
|
|
for path, sha in event['boot']['files'].items():
|
|
require(file_hash(path) == sha, 'boot payload changed')
|
|
observation = json.loads(read(self.source / 'postboot-preservation.json'))
|
|
require(observation == dict(version=1, authority_digest=self.binding['authority_digest'],
|
|
plan_sha256=self.binding['plan_sha256'], boot_id=boot_id(), preservation_verified=True),
|
|
'external postboot preservation observation required')
|
|
self.verify_installed()
|
|
return observation
|
|
|
|
def reconcile(self):
|
|
self.check_custody()
|
|
"""Evidence-only reconciliation. Never dpkg --configure -a or apt -f."""
|
|
require(self.status['phase'] in ('APPLY_INTENT', 'VERIFY', 'RECONCILIATION_REQUIRED'),
|
|
'no interrupted OS effects to reconcile')
|
|
observed = inventory()
|
|
atomic(self.root / ('reconciliation-inventory-%016d' % (self.status['sequence'] + 1)), observed)
|
|
self.journal('RECONCILIATION_REQUIRED', inventory_sha256=digest(observed),
|
|
dpkg_audit=command(['/usr/bin/dpkg', '--audit']),
|
|
automatic_retry_permitted=False)
|
|
return self.status
|
|
|
|
|
|
def stage(binding, action, phase='prepare', checkpoint=None):
|
|
"""Called only by privileged D09 worker under its live-owner continuity lock."""
|
|
if 'os_update' not in binding:
|
|
return None
|
|
require(action == 'APPLY', 'OS update cannot be restored by userspace rollback')
|
|
require(phase in ('prepare', 'apply', 'postboot', 'reconcile'), 'fixed OS operation')
|
|
custody.refuse_legacy()
|
|
os.close(secure(ROOT, True))
|
|
root_pin = custody.Pin(ROOT)
|
|
lock = os.open('executor.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=root_pin.fd)
|
|
executor = None
|
|
try:
|
|
root_pin.check()
|
|
checked = secure(ROOT / 'executor.lock')
|
|
try:
|
|
require((os.fstat(checked).st_dev, os.fstat(checked).st_ino) == (os.fstat(lock).st_dev, os.fstat(lock).st_ino),
|
|
'OS executor lock replaced')
|
|
finally:
|
|
os.close(checked)
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
executor = Executor(binding['os_update'])
|
|
executor.checkpoint = checkpoint
|
|
return {'prepare': executor.prepare, 'apply': executor.apply,
|
|
'postboot': executor.confirm_postboot, 'reconcile': executor.reconcile}[phase]()
|
|
finally:
|
|
if executor is not None:
|
|
executor.close()
|
|
os.close(lock)
|
|
root_pin.close()
|