142 lines
7.2 KiB
Python
142 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify release signatures with independent OpenSSL and native/static ARM tools."""
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
|
|
project = Path(__file__).resolve().parents[2]
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--host-only', action='store_true')
|
|
args = parser.parse_args()
|
|
work = Path(tempfile.mkdtemp(prefix='m12-signing.', dir=project / 'out'))
|
|
host = project / 'target/x86_64-unknown-linux-gnu/release/fds-release'
|
|
arm = project / 'target/aarch64-unknown-linux-musl/release/fds-release'
|
|
commands = [('host', [str(host)])]
|
|
if not args.host_only:
|
|
commands.append(('arm', [str(project / 'tools/in-void'), '--isolated-network', 'qemu-aarch64', str(arm)]))
|
|
log = (work / 'commands.log').open('w')
|
|
|
|
def invoke(prefix, arguments, ok=True):
|
|
result = subprocess.run([*prefix, *map(str, arguments)], capture_output=True, text=True, timeout=60)
|
|
log.write('COMMAND ' + repr([*prefix, *map(str, arguments)]) + '\n' + result.stdout + result.stderr)
|
|
log.flush()
|
|
assert (result.returncode == 0) == ok, (arguments, result.returncode, result.stdout, result.stderr)
|
|
return result.stdout + result.stderr
|
|
|
|
def check_all(directory, key, ok=True):
|
|
for label, command in commands:
|
|
result = invoke(command, ['verify', directory, '--key', key], ok)
|
|
if ok:
|
|
expected=json.loads((directory/'manifest.json').read_text())['version']
|
|
assert f'VERIFIED FDS/OS {expected}' in result
|
|
|
|
key_prefix = work / 'test-key'
|
|
invoke([str(host)], ['keygen', key_prefix])
|
|
private, public = work / 'test-key.key', work / 'test-key.pub'
|
|
assert private.stat().st_size == 32 and stat.S_IMODE(private.stat().st_mode) == 0o600
|
|
invoke([str(host)], ['keygen', key_prefix], False)
|
|
invoke([str(host)], ['public-key', private, work / 'exported.pub'])
|
|
assert (work / 'exported.pub').read_bytes() == public.read_bytes()
|
|
release = work / 'release'
|
|
release.mkdir()
|
|
payload = b'FDS signed artifact acceptance.\n' * 8192
|
|
(release / 'image.img').write_bytes(payload)
|
|
manifest = {'format': 1, 'version': '0.1.0', 'source_epoch': 1,
|
|
'source_sha256': hashlib.sha256(b'fixture source').hexdigest(),
|
|
'void_commit': (project / 'VOID_PACKAGES_COMMIT').read_text().strip(),
|
|
'hardware_validation': 'deferred',
|
|
'files': [{'name': 'image.img', 'bytes': len(payload), 'sha256': hashlib.sha256(payload).hexdigest()}]}
|
|
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
|
invoke([str(host)], ['sign', release, '--key', private])
|
|
check_all(release, public)
|
|
invoke([str(host)], ['sign', release, '--key', private], False)
|
|
# Keep the historical release fixture, and also sign the current Git-derived identity.
|
|
current = work / 'current-version'; current.mkdir()
|
|
shutil.copy2(release / 'image.img', current / 'image.img')
|
|
current_manifest = dict(manifest, version=subprocess.check_output([str(project/'tools/version')], text=True).strip())
|
|
(current / 'manifest.json').write_text(json.dumps(current_manifest))
|
|
invoke([str(host)], ['sign', current, '--key', private])
|
|
check_all(current, public)
|
|
|
|
# Independent standard Ed25519 verification/signing over the exact domain prefix
|
|
# and raw manifest bytes. Test-only DER files are private and never printed.
|
|
message = work / 'message'
|
|
message.write_bytes(b'FDS/OS release manifest v1\0' + (release / 'manifest.json').read_bytes())
|
|
private_der = work / 'private.der'
|
|
fd = os.open(private_der, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(fd, 'wb') as stream:
|
|
stream.write(bytes.fromhex('302e020100300506032b657004220420') + private.read_bytes())
|
|
public_der = work / 'public.der'
|
|
public_der.write_bytes(bytes.fromhex('302a300506032b6570032100') + bytes.fromhex(public.read_text().strip()))
|
|
signature = work / 'signature.bin'
|
|
signature.write_bytes(bytes.fromhex((release / 'manifest.sig').read_text().strip()))
|
|
invoke(['openssl'], ['pkeyutl', '-verify', '-rawin', '-pubin', '-inkey', public_der, '-keyform', 'DER', '-in', message, '-sigfile', signature])
|
|
invoke(['openssl'], ['pkeyutl', '-sign', '-rawin', '-inkey', private_der, '-keyform', 'DER', '-in', message, '-out', work / 'openssl.sig'])
|
|
assert (work / 'openssl.sig').read_bytes() == signature.read_bytes()
|
|
(release / 'manifest.sig').write_text((work / 'openssl.sig').read_bytes().hex() + '\n')
|
|
check_all(release, public)
|
|
print('PASS: actual key generation, no-overwrite policy, deterministic signatures and independent OpenSSL interoperability', flush=True)
|
|
|
|
other = work / 'other-key'
|
|
invoke([str(host)], ['keygen', other])
|
|
check_all(release, work / 'other-key.pub', False)
|
|
weak = work / 'weak.pub'
|
|
weak.write_text('01' + '00' * 31 + '\n')
|
|
check_all(release, weak, False)
|
|
private.chmod(0o644)
|
|
invoke([str(host)], ['public-key', private, work / 'insecure.pub'], False)
|
|
private.chmod(0o600)
|
|
|
|
for name in ['payload', 'size', 'missing', 'signature', 'manifest', 'symlink', 'fifo']:
|
|
damaged = work / ('bad-' + name)
|
|
shutil.copytree(release, damaged)
|
|
target = damaged / 'image.img'
|
|
if name == 'payload':
|
|
data = bytearray(target.read_bytes()); data[4096] ^= 1; target.write_bytes(data)
|
|
elif name == 'size':
|
|
with target.open('ab') as stream: stream.write(b'X')
|
|
elif name == 'missing': target.unlink()
|
|
elif name == 'signature': (damaged / 'manifest.sig').write_text('00' * 64 + '\n')
|
|
elif name == 'manifest':
|
|
# A semantically identical JSON document still changes the signed bytes.
|
|
with (damaged / 'manifest.json').open('a') as stream: stream.write('\n')
|
|
elif name == 'symlink':
|
|
target.unlink(); target.symlink_to(release / 'image.img')
|
|
elif name == 'fifo':
|
|
target.unlink(); os.mkfifo(target, 0o600)
|
|
check_all(damaged, public, False)
|
|
|
|
for name in ['traversal', 'duplicate', 'unknown', 'wrong-version', 'unbounded']:
|
|
bad = work / ('manifest-' + name)
|
|
bad.mkdir()
|
|
value = copy.deepcopy(manifest)
|
|
if name == 'traversal': value['files'][0]['name'] = '../image.img'
|
|
elif name == 'duplicate': value['files'].append(copy.deepcopy(value['files'][0]))
|
|
elif name == 'unknown': value['execute'] = 'sh'
|
|
elif name == 'wrong-version': value['version'] = '../invalid'
|
|
(bad / 'manifest.json').write_text(json.dumps(value) + (' ' * (1024 * 1024) if name == 'unbounded' else ''))
|
|
(bad / 'manifest.sig').write_bytes((release / 'manifest.sig').read_bytes())
|
|
check_all(bad, public, False)
|
|
|
|
# The ARM signer must produce the same bytes for the same manifest and key.
|
|
if not args.host_only:
|
|
counterpart = work / 'arm-signed'
|
|
shutil.copytree(release, counterpart)
|
|
(counterpart / 'manifest.sig').unlink()
|
|
invoke(commands[1][1], ['sign', counterpart, '--key', private])
|
|
assert (counterpart / 'manifest.sig').read_bytes() == (release / 'manifest.sig').read_bytes()
|
|
check_all(counterpart, public)
|
|
log.close()
|
|
link = project / ('out/m12-signing-host-latest.next' if args.host_only else 'out/m12-signing-latest.next')
|
|
link.symlink_to(work.name)
|
|
link.replace(link.with_suffix(''))
|
|
print(f'PASS: {"host" if args.host_only else "host and actual static ARM"} signature/artifact verification and rejection matrix: {work}', flush=True)
|
|
print('NOTE: this is a tool acceptance fixture, not a signed FDS/OS release or a secure-boot claim')
|