297 lines
14 KiB
Python
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()
|