Add signed passive runtime updates and independent crash recovery
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build reproducible, passive Ed25519-signed runtime release artifacts."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
ENGINE = runpy.run_path(str(Path(__file__).resolve().parents[1] / 'hydra/update_engine.py'))
|
||||
|
||||
|
||||
def build(source, output, private_key, version, sequence):
|
||||
source, output, private_key = Path(source).resolve(), Path(output).absolute(), Path(private_key).absolute()
|
||||
if private_key.is_symlink() or not private_key.is_file():
|
||||
raise ValueError('Private key must be an existing regular file outside Git')
|
||||
info = private_key.stat()
|
||||
if info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise ValueError('Private key must be user-owned and private (0600)')
|
||||
git = subprocess.run(['git', '-C', str(private_key.parent), 'rev-parse', '--show-toplevel'], capture_output=True, text=True)
|
||||
if private_key.is_relative_to(source) or git.returncode == 0:
|
||||
raise ValueError('Private key must remain outside Git and the source/build context')
|
||||
commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip()
|
||||
files = {}
|
||||
for path in sorted((source / 'hydra').glob('*.py')):
|
||||
name = 'usr/lib/hydra/hydra/' + path.name
|
||||
if ENGINE['allowed'](name):
|
||||
if path.is_symlink():
|
||||
raise ValueError('Source payload symlink refused')
|
||||
files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644)
|
||||
files['usr/lib/hydra/bin/hydra'] = (ENGINE['read_bytes'](source / 'bin/hydra', ENGINE['FILE_LIMIT']), 0o755)
|
||||
for name in sorted(ENGINE['EXACT'] - {'usr/lib/hydra/bin/hydra'}):
|
||||
path = source / 'image/rootfs' / name
|
||||
if path.exists():
|
||||
files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644)
|
||||
# Construct privately, verify our complete artifacts, then publish a new directory.
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-release-build-') as temporary:
|
||||
temporary = Path(temporary)
|
||||
with zipfile.ZipFile(temporary / 'runtime.zip', 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, (data, mode) in sorted(files.items()):
|
||||
info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0))
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFREG | mode) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, data, compresslevel=9)
|
||||
data = ENGINE['read_bytes'](temporary / 'runtime.zip', ENGINE['LIMIT'])
|
||||
manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable',
|
||||
'architecture': 'x86_64', 'version': version, 'sequence': sequence, 'source_commit': commit,
|
||||
'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': ENGINE['digest'](data)},
|
||||
'files': {name: {'size': len(content), 'mode': mode, 'sha256': ENGINE['digest'](content)}
|
||||
for name, (content, mode) in sorted(files.items())}}
|
||||
raw = (json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n').encode()
|
||||
(temporary / 'manifest.json').write_bytes(raw)
|
||||
subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(private_key), '-in', str(temporary / 'manifest.json'),
|
||||
'-out', str(temporary / 'manifest.sig')], check=True, capture_output=True, timeout=15)
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(private_key), '-pubout', '-out', str(temporary / 'public.pem')],
|
||||
check=True, capture_output=True, timeout=15)
|
||||
ENGINE['validate_manifest'](raw, (temporary / 'manifest.sig').read_bytes(), temporary / 'public.pem')
|
||||
ENGINE['validate_archive'](data, manifest)
|
||||
output.mkdir(mode=0o755) # Existing version directories are immutable.
|
||||
try:
|
||||
for name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
||||
ENGINE['atomic_write'](output / name, (temporary / name).read_bytes(), 0o644)
|
||||
ENGINE['fsync_dir'](output.parent)
|
||||
except BaseException:
|
||||
shutil.rmtree(output)
|
||||
raise
|
||||
return manifest
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--private-key', type=Path, required=True)
|
||||
parser.add_argument('--version', required=True)
|
||||
parser.add_argument('--sequence', type=int, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
print(json.dumps(build(args.source, args.output, args.private_key, args.version, args.sequence), indent=2))
|
||||
return 0
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
print('hydra-release: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user