Files
archy/scripts/public_update_prepare.py

297 lines
14 KiB
Python

#!/usr/bin/env python3
"""Prepare immutable, authenticated application-update inputs on legacy nodes.
No activation, package operation, service stop, or protected-runtime fallback.
The production entry point has fixed staging, verifier and snapshot roots.
"""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tarfile
import uuid
STAGING = Path('/var/lib/archipelago/update-staging')
SNAPSHOTS = Path('/var/lib/archipelago-public-updates')
VERIFIER = Path('/usr/local/bin/archipelago')
POLICY = {'version': 1, 'mode': 'signed-application-only'}
RUNTIME_POLICY = {'version': 2, 'mode': 'signed-application-runtime'}
RELEASE_ROOT = 'did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT'
MAX_DOCUMENT = 65536
MAX_COMPONENT = 2 * 1024**3
def pairs(items):
result = {}
for key, value in items:
if key in result:
raise ValueError('duplicate document field')
result[key] = value
return result
def version(value):
if not isinstance(value, str) or not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.]+)?', value):
raise ValueError('invalid release version')
return tuple(map(int, value.split('-')[0].split('.')))
def validate_document(document, running_version):
if (document.get('signed_by') != RELEASE_ROOT or
not isinstance(document.get('signature'), str) or
not re.fullmatch('[0-9a-f]{128}', document['signature'])):
raise ValueError('release-root signature fields required')
policy = document.get('application_update')
if (policy not in (POLICY, RUNTIME_POLICY) or not isinstance(policy, dict) or
type(policy.get('version')) is not int or 'os_update' in document):
raise ValueError('application-only release authorization required')
if version(document['version']) <= version(running_version):
raise ValueError('release is not newer than the installed verifier')
components = document['components']
expected = {'archipelago', f"archipelago-frontend-{document['version']}.tar.gz"}
if not isinstance(components, list) or len(components) != 2 or {c['name'] for c in components} != expected:
raise ValueError('exact application component pair required')
for component in components:
if (component['new_version'] != document['version'] or
not re.fullmatch('[0-9a-f]{64}', component['sha256']) or
type(component['size_bytes']) is not int or
not 0 < component['size_bytes'] <= MAX_COMPONENT):
raise ValueError('invalid component identity')
return components
def trusted_open(path, directory=False):
"""Pin root-owned path components without following links."""
if not path.is_absolute() or '..' in path.parts:
raise ValueError('canonical absolute custody path required')
fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
try:
for i, part in enumerate(path.parts[1:]):
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
if i < len(path.parts[1:]) - 1 or directory:
flags |= os.O_DIRECTORY
child = os.open(part, flags, dir_fd=fd)
os.close(fd); fd = child
info = os.fstat(fd)
if info.st_uid != 0 or info.st_mode & 0o022:
raise ValueError('mutable root custody path')
if not directory and not stat.S_ISREG(os.fstat(fd).st_mode):
raise ValueError('regular installed verifier required')
return os.dup(fd)
finally:
os.close(fd)
def authenticate(raw):
"""Use the installed trusted verifier, not the downloaded candidate."""
verifier = trusted_open(VERIFIER)
document = os.memfd_create('public-update-manifest', os.MFD_ALLOW_SEALING)
try:
with os.fdopen(os.dup(document), 'wb') as stream:
stream.write(raw)
fcntl.fcntl(document, fcntl.F_ADD_SEALS,
fcntl.F_SEAL_WRITE | fcntl.F_SEAL_GROW | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_SEAL)
env = {'PATH': '/usr/bin:/bin', 'HOME': '/nonexistent', 'LANG': 'C'}
subprocess.run([f'/proc/self/fd/{verifier}', 'ceremony', 'verify', f'/proc/self/fd/{document}'],
pass_fds=(verifier, document), env=env, cwd='/', check=True,
stdout=subprocess.DEVNULL, timeout=60)
output = subprocess.check_output([f'/proc/self/fd/{verifier}', '--version'],
pass_fds=(verifier,), env=env, cwd='/', text=True,
timeout=30)
match = re.fullmatch(r'archipelago (\S+)\s*', output)
if not match:
raise ValueError('installed verifier version unavailable')
# Released binaries print CARGO_PKG_VERSION-GIT_HASH; /health and the
# signed release use CARGO_PKG_VERSION. Never compare a build suffix as
# though it were a different release, or reject real older verifiers.
reported = match[1]
version_match = re.fullmatch(r'([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.]+)?)-(?:[0-9a-f]{7,40}|dev)', reported)
return version_match[1] if version_match else reported
finally:
os.close(verifier); os.close(document)
def require_legacy_service():
# Probe the owner directories too: a dangling directory symlink or partial
# provisioning must not become absence merely because a child is missing.
for path in ('/etc/archipelago-d09', '/etc/archipelago-d09/binding.json',
'/var/lib/archipelago-d09', '/run/archipelago-d09',
'/etc/archipelago-c01', '/etc/archipelago-c01/launch.json',
'/usr/libexec/archipelago-d09ctl', '/usr/libexec/archipelago-d09-owner',
'/etc/systemd/system/archipelago-d09-lifecycle.service',
'/usr/lib/systemd/system/archipelago-d09-lifecycle.service',
'/lib/systemd/system/archipelago-d09-lifecycle.service',
'/etc/systemd/system/archipelago.service.d/50-d09-lifecycle.conf',
'/etc/systemd/system/archipelago.service.d/60-terminal-workspace.conf'):
try:
os.lstat(path)
except FileNotFoundError:
continue
raise ValueError('existing protected updater ownership requires its own admission route')
output = subprocess.check_output(['/usr/bin/systemctl', 'show', 'archipelago.service',
'--property=LoadState', '--property=User', '--property=ExecStart'], text=True,
timeout=30)
properties = dict(line.split('=', 1) for line in output.splitlines() if '=' in line)
command = properties.get('ExecStart', '')
if (properties.get('LoadState') != 'loaded' or properties.get('User') != 'archipelago' or
command.count('path=') != 1 or
not re.search(r'path=/usr/local/bin/archipelago\s*;', command) or
not re.search(r'argv\[\]=/usr/local/bin/archipelago\s*;', command)):
raise ValueError('standard legacy service required; protected/custom runtime is not a fallback target')
def copy_component(source_dir, destination, component):
fd = os.open(component['name'], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=source_dir)
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode) or before.st_size != component['size_bytes']:
raise ValueError('staged component type/size mismatch')
digest = hashlib.sha256(); size = 0
with os.fdopen(os.dup(fd), 'rb') as source, destination.open('xb') as target:
os.fchmod(target.fileno(), 0o600)
while block := source.read(1024 * 1024):
size += len(block)
if size > component['size_bytes']:
raise ValueError('staged component grew during copy')
digest.update(block); target.write(block)
target.flush(); os.fsync(target.fileno())
after = os.fstat(fd)
if (size != component['size_bytes'] or digest.hexdigest() != component['sha256'] or
(before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) !=
(after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns)):
raise ValueError('staged component changed or differs from signed bytes')
with destination.open('rb') as frozen:
os.fchmod(frozen.fileno(), 0o444)
os.fsync(frozen.fileno())
finally:
os.close(fd)
def validate_archive(path, require_declaration=True):
seen = {}; total = 0
with tarfile.open(path, 'r:gz') as archive:
for member in archive:
name = member.name.removeprefix('./').rstrip('/')
if name in ('', '.') and member.isdir():
continue
if (name.startswith('/') or '..' in Path(name).parts or name != Path(name).as_posix() or name in seen or
not (member.isdir() or member.isfile()) or member.mode & 0o6000):
raise ValueError('unsafe or duplicate application archive member')
# A file used as an ancestor is invalid even when the archive puts
# the child first. Reject both orders before any extraction.
if any(seen.get(parent.as_posix()) == 'file' for parent in Path(name).parents):
raise ValueError('archive file used as a directory')
if member.isfile() and any(prior.startswith(name + '/') for prior in seen):
raise ValueError('archive file shadows a directory')
seen[name] = 'directory' if member.isdir() else 'file'
total += member.size
if len(seen) > 20000 or total > 4 * 1024**3:
raise ValueError('application archive exceeds bounds')
if (seen.get('index.html') != 'file' or
(require_declaration and seen.get('archipelago-runtime/RELEASE-PAYLOAD.json') != 'file')):
raise ValueError('application archive lacks required payload declarations')
return total, len(seen)
def sync_directory(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def write_manifest(path, raw):
# Persist bytes AND final metadata before publishing the containing name.
with path.open('xb') as stream:
stream.write(raw)
stream.flush()
os.fchmod(stream.fileno(), 0o444)
os.fsync(stream.fileno())
def main():
if len(sys.argv) != 1:
raise ValueError('this entry point accepts no path or mode overrides')
if os.geteuid() != 0:
raise PermissionError('root-owned public update preparation required')
raw = sys.stdin.buffer.read(MAX_DOCUMENT + 1)
if not raw or len(raw) > MAX_DOCUMENT:
raise ValueError('manifest size invalid')
document = json.loads(raw, object_pairs_hook=pairs)
components = validate_document(document, authenticate(raw))
require_legacy_service()
os.close(trusted_open(SNAPSHOTS.parent, directory=True))
SNAPSHOTS.mkdir(mode=0o755, exist_ok=True)
os.close(trusted_open(SNAPSHOTS, directory=True))
sync_directory(SNAPSHOTS.parent)
lock_fd = os.open(SNAPSHOTS / '.prepare.lock',
os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
with os.fdopen(lock_fd, 'w') as lock:
info = os.fstat(lock.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise ValueError('untrusted preparation lock')
fcntl.flock(lock, fcntl.LOCK_EX)
require_legacy_service()
identity = [document['version'], sorted([
[c['name'], c['new_version'], c['sha256'], c['size_bytes']] for c in components])]
name = hashlib.sha256(json.dumps(identity, separators=(',', ':'), ensure_ascii=True).encode()).hexdigest()
ready = SNAPSHOTS / name
if os.path.lexists(ready):
os.close(trusted_open(ready, directory=True))
for component in components:
fd = trusted_open(ready / component['name'])
try:
if (os.fstat(fd).st_mode & 0o222 or
os.fstat(fd).st_size != component['size_bytes']):
raise ValueError('prepared artifact is writable')
with os.fdopen(os.dup(fd), 'rb') as stream:
if hashlib.file_digest(stream, 'sha256').hexdigest() != component['sha256']:
raise ValueError('prepared artifact digest changed')
finally:
os.close(fd)
manifest_fd = trusted_open(ready / 'signed-manifest.json')
try:
if os.fstat(manifest_fd).st_mode & 0o222:
raise ValueError('prepared manifest is writable')
with os.fdopen(os.dup(manifest_fd), 'rb') as stream:
saved = stream.read(MAX_DOCUMENT + 1)
finally:
os.close(manifest_fd)
if len(saved) > MAX_DOCUMENT:
raise ValueError('prepared manifest size invalid')
prior = json.loads(saved, object_pairs_hook=pairs)
prior_components = validate_document(prior, authenticate(saved))
if sorted([[c['name'], c['new_version'], c['sha256'], c['size_bytes']] for c in prior_components]) != identity[1]:
raise ValueError('prepared manifest identity differs')
print(json.dumps({'version': 1, 'path': str(ready), 'manifest_sha256': hashlib.sha256(saved).hexdigest()}))
return
free = os.statvfs(SNAPSHOTS)
if free.f_bavail * free.f_frsize < sum(c['size_bytes'] for c in components) + 256 * 1024**2:
raise ValueError('insufficient snapshot space')
prepared = SNAPSHOTS / ('.partial-' + uuid.uuid4().hex)
prepared.mkdir(mode=0o700)
# Failed snapshots remain root-private evidence. Never reuse partial files.
source_dir = os.open(STAGING, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
for component in components:
copy_component(source_dir, prepared / component['name'], component)
validate_archive(prepared / f"archipelago-frontend-{document['version']}.tar.gz")
write_manifest(prepared / 'signed-manifest.json', raw)
prepared.chmod(0o755)
sync_directory(prepared)
prepared.rename(ready)
sync_directory(SNAPSHOTS)
print(json.dumps({'version': 1, 'path': str(ready), 'manifest_sha256': hashlib.sha256(raw).hexdigest()}))
finally:
os.close(source_dir)
if __name__ == '__main__':
main()