#!/usr/bin/env python3
"""Offline FreeCORE validation signing and independent public verification.

Protocol v1: DSSE + binary OpenPGP Ed25519Legacy/SHA256 signatures. No network,
key generation, default keyring, publication, or QA execution is provided here.
"""
from __future__ import annotations

import argparse
import base64
import binascii
import hashlib
import io
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import zipfile
from datetime import datetime, timezone

PAYLOAD_TYPE = 'application/vnd.in-toto+json'
UID = 'FreeCORE Validation Signing <validation@freecore.org>'
FILES = {'VALIDATION.md', 'parity-results.json', 'feature-results.json',
         'coverage-policy.json', 'validation-attestation.json', 'SHA256SUMS'}
OUTER = {'validation.dsse.json', 'SHA256SUMS.gpg', 'validation-key.asc'}
ARCHIVE_FILES = {'payload/' + name for name in FILES} | OUTER
LIMIT = 16 * 1024 * 1024
TOTAL_LIMIT = 64 * 1024 * 1024
HEX = re.compile(r'[0-9a-f]{64}')
FINGERPRINT = re.compile(r'[0-9A-F]{40}')


class Invalid(ValueError):
    pass


def require(condition, message):
    if not condition:
        raise Invalid(message)


def sha(data):
    return hashlib.sha256(data).hexdigest()


def exact(value, keys, label):
    require(isinstance(value, dict) and set(value) == set(keys), label + ': unexpected fields')
    return value


def strict_json(data):
    def pairs(items):
        result = {}
        for key, value in items:
            require(key not in result, 'Duplicate JSON key')
            result[key] = value
        return result
    def constant(_):
        raise Invalid('Nonstandard JSON number')
    try:
        return json.loads(data, object_pairs_hook=pairs, parse_constant=constant)
    except (ValueError, UnicodeError) as exc:
        raise Invalid('Invalid or ambiguous JSON') from exc


def encode(value):
    return (json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + '\n').encode()


def regular(path):
    path = Path(os.path.abspath(path))
    require(path == path.resolve() and stat.S_ISREG(path.lstat().st_mode), 'Input must be a regular non-symlink file')
    flags = os.O_RDONLY | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0)
    with os.fdopen(os.open(path, flags), 'rb') as stream:
        require(stat.S_ISREG(os.fstat(stream.fileno()).st_mode), 'Input type changed')
        data = stream.read(TOTAL_LIMIT + 1)
    require(len(data) <= TOTAL_LIMIT, 'Input too large')
    return data


def directory(path):
    path = Path(os.path.abspath(path))
    require(path == path.resolve() and path.is_dir(), 'Directory must be real, not a symlink')
    return path


def bundle_files(path):
    path = directory(path)
    require({p.name for p in path.iterdir()} == FILES, 'Unsigned bundle file set mismatch')
    data = {name: regular(path / name) for name in FILES}
    require(all(len(v) <= LIMIT for v in data.values()) and sum(map(len, data.values())) <= TOTAL_LIMIT,
            'Bundle too large')
    return data


def timestamp(value):
    require(isinstance(value, str) and re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z', value), 'Invalid UTC timestamp')
    try:
        return datetime.strptime(value, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc).timestamp()
    except ValueError as exc:
        raise Invalid('Invalid timestamp') from exc


def policy_check(policy, key, now):
    exact(policy, {'schemaVersion', 'role', 'primaryFingerprint', 'signingFingerprint',
                   'publicKeySha256', 'notBefore', 'notAfter', 'revokedFingerprints'}, 'Trust policy')
    require(type(policy['schemaVersion']) is int and policy['schemaVersion'] == 1 and policy['role'] == 'freecore-validation', 'Wrong validation trust role/version')
    for field in ('primaryFingerprint', 'signingFingerprint'):
        require(isinstance(policy[field], str) and FINGERPRINT.fullmatch(policy[field]), 'Invalid pinned fingerprint')
    require(policy['primaryFingerprint'] != policy['signingFingerprint'], 'Dedicated signing subkey required')
    require(isinstance(policy['publicKeySha256'], str) and HEX.fullmatch(policy['publicKeySha256']) and sha(key) == policy['publicKeySha256'], 'Public key digest mismatch')
    require(timestamp(policy['notBefore']) <= now < timestamp(policy['notAfter']), 'Trust policy inactive or expired')
    revoked = policy['revokedFingerprints']
    require(isinstance(revoked, list) and all(isinstance(x, str) and FINGERPRINT.fullmatch(x) for x in revoked), 'Malformed revocation list')
    require(not {policy['primaryFingerprint'], policy['signingFingerprint']} & set(revoked), 'Validation key revoked by policy')
    require(key.startswith(b'-----BEGIN PGP PUBLIC KEY BLOCK-----\n') and key.rstrip().endswith(b'-----END PGP PUBLIC KEY BLOCK-----')
            and key.count(b'-----BEGIN') == 1 and b'PRIVATE' not in key and len(key) < 65536, 'Only one armored public certificate is accepted')


def expected_check(files, expected):
    exact(expected, {'artifactSha256', 'artifactSize', 'version', 'profile', 'train', 'sourceLine',
                     'architecture', 'sourceManifestSha256', 'sha256sumsSha256', 'denylistSha256'}, 'Expected identity')
    for field in ('artifactSha256', 'sourceManifestSha256', 'sha256sumsSha256', 'denylistSha256'):
        require(isinstance(expected[field], str) and HEX.fullmatch(expected[field]), 'Invalid expected digest')
    require(type(expected['artifactSize']) is int and expected['artifactSize'] > 0, 'Invalid expected artifact size')
    require(isinstance(expected['profile'], str) and expected['profile'] in {'15.0-maintenance', '15.1'} and expected['sourceLine'] == expected['profile'] and expected['architecture'] == 'amd64', 'Unsupported profile/source/architecture')
    require(all(isinstance(expected[x], str) and expected[x] for x in ('version', 'train')), 'Missing expected release identity')
    require(sha(files['SHA256SUMS']) == expected['sha256sumsSha256'], 'Checksum inventory differs from reviewed identity')
    try:
        lines = files['SHA256SUMS'].decode('ascii').splitlines(keepends=True)
    except UnicodeError as exc:
        raise Invalid('Checksum inventory is not ASCII') from exc
    declared = {}
    for line in lines:
        match = re.fullmatch(r'([0-9a-f]{64})  ([A-Za-z0-9.-]+)\n', line)
        require(match is not None, 'Malformed checksum row')
        digest, name = match.groups()
        require(name not in declared, 'Duplicate checksum row')
        declared[name] = digest
    require(set(declared) == FILES - {'SHA256SUMS'}, 'Checksum payload inventory mismatch')
    require(all(sha(files[n]) == d for n, d in declared.items()), 'Payload checksum mismatch')
    documents = {n: strict_json(v) for n, v in files.items() if n.endswith('.json')}
    a = exact(documents['validation-attestation.json'], {'_type', 'subject', 'predicateType', 'predicate'}, 'Statement')
    require(a['_type'] == 'https://in-toto.io/Statement/v1' and a['predicateType'] == 'https://freecore.org/attestations/validation/v1', 'Wrong attestation type')
    require(a['subject'] == [{'name': 'FreeCORE installation image', 'digest': {'sha256': expected['artifactSha256']}}], 'Wrong artifact subject')
    p = a['predicate']
    require(isinstance(p, dict), 'Malformed predicate')
    for field in ('version', 'profile', 'train', 'sourceLine', 'architecture', 'artifactSize'):
        require(p.get(field) == expected[field], 'Attestation release binding mismatch: ' + field)
    require(p.get('sourceManifest') == {'sha256': expected['sourceManifestSha256']}, 'Source manifest mismatch')
    require(p.get('publicationPolicy') == {'canonicalDenylistSha256': expected['denylistSha256'], 'warningsAccepted': False}, 'Publication policy mismatch')
    require(p.get('results') == {k: {'sha256': sha(files[n])} for k, n in (
        ('parity', 'parity-results.json'), ('features', 'feature-results.json'), ('coveragePolicy', 'coverage-policy.json'))}, 'Result digest bindings mismatch')
    summary = p.get('summary', {})
    require(isinstance(summary, dict), 'Malformed attestation summary')
    require(summary.get('candidateRegressions') == 0 and type(summary.get('candidateRegressions')) is int, 'Attested regressions')
    count = summary.get('featureChecksRequired')
    require(type(count) is int and count > 0 and summary.get('featureChecksPassed') == count, 'Attested feature gate is not passing')
    # Detailed schema/privacy acceptance belongs to the pinned offline QA verifier.
    # Public verification authenticates those signed claims; it does not rerun QA.
    return a


def pae(payload_type, body):
    kind = payload_type.encode('utf-8')
    return b'DSSEv1 ' + str(len(kind)).encode('ascii') + b' ' + kind + b' ' + str(len(body)).encode('ascii') + b' ' + body


def b64(value):
    require(isinstance(value, str) and len(value) <= TOTAL_LIMIT * 2, 'Invalid base64 type/size')
    try:
        # DSSE permits both standard and URL-safe alphabets. No whitespace or junk.
        require(re.fullmatch(r'[A-Za-z0-9+/_-]*={0,2}', value) is not None, 'Invalid base64 alphabet')
        return base64.b64decode(value, altchars=b'-_', validate=True)
    except (ValueError, binascii.Error) as exc:
        raise Invalid('Malformed base64') from exc


def gpg_command(gpg, home, args, data=None):
    command = [gpg, '--no-options', '--homedir', str(home), '--batch', '--no-tty',
               '--no-auto-key-retrieve', '--auto-key-locate', 'clear', *args]
    if '--detach-sign' not in args:
        command.insert(1, '--no-autostart')
    result = subprocess.run(command, input=data, capture_output=True, timeout=60,
                            env={'PATH': os.defpath, 'LC_ALL': 'C', 'GNUPGHOME': str(home)})
    require(result.returncode == 0, 'GnuPG refused the operation (details suppressed)')
    return result.stdout


def import_public(gpg, home, key, policy, now):
    policy_check(policy, key, now)
    # Dry listing detects secret packets before an import can write a private key.
    shown = gpg_command(gpg, home, ['--with-colons', '--import-options', 'show-only', '--import'], key)
    require(not any(line.startswith((b'sec:', b'ssb:')) for line in shown.splitlines()), 'Secret key material rejected')
    gpg_command(gpg, home, ['--import'], key)
    require(not gpg_command(gpg, home, ['--with-colons', '--list-secret-keys']).strip(), 'Secret material in verification keyring')
    listing = gpg_command(gpg, home, ['--with-colons', '--fixed-list-mode', '--list-keys']).decode('utf-8')
    keys, uids, current = [], [], None
    for line in listing.splitlines():
        row = line.split(':')
        if row[0] in ('pub', 'sub'):
            require(len(row) > 16 and row[1] not in {'r', 'e', 'd', 'i'} and row[3] == '22' and row[16] == 'ed25519', 'Unsupported, invalid, revoked or expired key')
            require(row[5].isdigit() and row[6].isdigit() and int(row[5]) <= now < int(row[6]), 'Key needs current finite lifetime')
            current = {'type': row[0], 'capabilities': row[11]}
            keys.append(current)
        elif row[0] == 'fpr' and current is not None:
            require('fingerprint' not in current, 'Ambiguous key fingerprint')
            current['fingerprint'] = row[9]
        elif row[0] == 'uid':
            require(row[1] not in {'r', 'e', 'd', 'i'}, 'Invalid validation identity')
            uids.append(row[9])
    require(len(keys) == 2 and [k['type'] for k in keys] == ['pub', 'sub'], 'Exactly one primary and one signing subkey required')
    require(keys[0].get('fingerprint') == policy['primaryFingerprint'] and keys[1].get('fingerprint') == policy['signingFingerprint'], 'Public key binding differs from pins')
    require('c' in keys[0]['capabilities'] and 's' in keys[1]['capabilities'] and uids == [UID], 'Wrong validation key capabilities/identity')


def single_signature_packet(signature):
    """Bound the transport to one definite-length OpenPGP signature packet.

    Cryptographic/subpacket validation remains GnuPG's job. This rejects extra
    OpenPGP packets or trailing bytes outside the detached signature itself.
    """
    require(isinstance(signature, bytes) and 3 <= len(signature) < 65536 and signature[0] & 0x80, 'Malformed signature packet')
    first, offset = signature[0], 1
    if first & 0x40:
        require(first & 0x3f == 2, 'Not an OpenPGP signature packet')
        length = signature[offset]
        offset += 1
        if 192 <= length <= 223:
            length = ((length - 192) << 8) + signature[offset] + 192
            offset += 1
        elif length == 255:
            require(len(signature) >= offset + 4, 'Truncated signature length')
            length = int.from_bytes(signature[offset:offset + 4], 'big')
            offset += 4
        else:
            require(length < 192, 'Partial signature packet rejected')
    else:
        require((first >> 2) & 0x0f == 2 and first & 3 != 3, 'Wrong/indeterminate OpenPGP packet')
        size = 1 << (first & 3)
        require(len(signature) >= offset + size, 'Truncated signature length')
        length = int.from_bytes(signature[offset:offset + size], 'big')
        offset += size
    require(length > 0 and offset + length == len(signature) and signature[offset] == 4, 'Extra bytes or unsupported signature version')


def verify_signature(gpg, home, signature, data, policy, now):
    single_signature_packet(signature)
    with tempfile.TemporaryDirectory(prefix='signature-', dir=home) as temp:
        path = Path(temp)
        (path / 'data').write_bytes(data)
        (path / 'signature').write_bytes(signature)
        status = gpg_command(gpg, home, ['--status-fd', '1', '--verify', str(path / 'signature'), str(path / 'data')]).decode('ascii')
    records = [line[9:].split() for line in status.splitlines() if line.startswith('[GNUPG:] ')]
    bad = {'BADSIG', 'ERRSIG', 'EXPSIG', 'EXPKEYSIG', 'REVKEYSIG', 'KEYREVOKED', 'KEYEXPIRED', 'SIGEXPIRED', 'NO_PUBKEY', 'FAILURE', 'ERROR'}
    require(not any(r and r[0] in bad for r in records), 'Signature validity status rejected')
    valid = [r for r in records if r and r[0] == 'VALIDSIG']
    require(len(valid) == 1 and len(valid[0]) == 11, 'Exactly one bound signature required')
    v = valid[0]
    require(v[1] == policy['signingFingerprint'] and v[10] == policy['primaryFingerprint'], 'Signature signer/primary binding mismatch')
    require(v[5] == '4' and v[7:10] == ['22', '8', '00'], 'Signature must be v4 Ed25519Legacy/SHA256 binary document')
    require(v[3].isdigit() and timestamp(policy['notBefore']) <= int(v[3]) <= now, 'Signature outside trusted time interval')
    require(v[4] == '0' or (v[4].isdigit() and now < int(v[4])), 'Signature expired')


def archive_bytes(members):
    stream = io.BytesIO()
    with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_STORED) as archive:
        for name, data in sorted(members.items()):
            info = zipfile.ZipInfo(name)
            info.create_system = 3
            info.external_attr = (stat.S_IFREG | 0o644) << 16
            archive.writestr(info, data)
    return stream.getvalue()


def archive_read(path):
    raw = regular(path)
    try:
        with zipfile.ZipFile(io.BytesIO(raw)) as archive:
            entries = archive.infolist()
            require(len(entries) == len(ARCHIVE_FILES) and {i.filename for i in entries} == ARCHIVE_FILES, 'Signed archive file set mismatch')
            require(not archive.comment and sum(i.file_size for i in entries) <= TOTAL_LIMIT, 'Archive metadata/size rejected')
            for item in entries:
                require(item.compress_type == zipfile.ZIP_STORED and not item.flag_bits & 1 and item.file_size <= LIMIT
                        and stat.S_ISREG(item.external_attr >> 16) and not item.extra and not item.comment, 'Unsafe archive entry')
            members = {i.filename: archive.read(i) for i in entries}
            require(raw == archive_bytes(members), 'Noncanonical archive metadata or undeclared bytes')
            return members
    except (zipfile.BadZipFile, RuntimeError) as exc:
        raise Invalid('Invalid signed archive') from exc


def verify_members(members, policy, expected, gpg):
    require(set(members) == ARCHIVE_FILES, 'Signed members mismatch')
    require(isinstance(policy, dict), 'Malformed trust policy')
    files = {name: members['payload/' + name] for name in FILES}
    expected_check(files, expected)
    envelope = exact(strict_json(members['validation.dsse.json']), {'payloadType', 'payload', 'signatures'}, 'DSSE envelope')
    require(envelope['payloadType'] == PAYLOAD_TYPE, 'Wrong DSSE payload type')
    payload = b64(envelope['payload'])
    require(payload == files['validation-attestation.json'], 'DSSE payload differs from accepted attestation bytes')
    signatures = envelope['signatures']
    require(isinstance(signatures, list) and len(signatures) == 1, 'Exactly one DSSE signature required')
    sig = exact(signatures[0], {'keyid', 'sig'}, 'DSSE signature')
    require(sig['keyid'] == policy.get('signingFingerprint'), 'Unexpected key hint')
    now = time.time()
    # GnuPG uses Unix sockets even when probing for an agent; keep names short
    # on macOS and FreeBSD. mkdtemp creates a private unpredictable directory.
    with tempfile.TemporaryDirectory(prefix='fcv-', dir='/tmp') as temp:
        home = Path(temp).resolve()
        import_public(gpg, home, members['validation-key.asc'], policy, now)
        verify_signature(gpg, home, b64(sig['sig']), pae(PAYLOAD_TYPE, payload), policy, now)
        verify_signature(gpg, home, members['SHA256SUMS.gpg'], files['SHA256SUMS'], policy, now)
    return {'status': 'verified', 'artifactSha256': expected['artifactSha256'], 'sha256sumsSha256': expected['sha256sumsSha256'], 'role': 'freecore-validation'}


def qa_gate(files, expected, verifier, common, denylist, pins):
    exact(pins, {'qaVerifierSha256', 'qaCommonSha256', 'denylistSha256'}, 'QA verifier pins')
    inputs = {'verify_validation_bundle.py': regular(verifier), 'validation_common.py': regular(common), 'deny.txt': regular(denylist)}
    for name, field in (('verify_validation_bundle.py', 'qaVerifierSha256'), ('validation_common.py', 'qaCommonSha256'), ('deny.txt', 'denylistSha256')):
        require(isinstance(pins[field], str) and HEX.fullmatch(pins[field]) and sha(inputs[name]) == pins[field], 'QA verifier/denylist input changed')
    require(pins['denylistSha256'] == expected['denylistSha256'], 'QA policy differs from expected release')
    with tempfile.TemporaryDirectory(prefix='freecore-validation-gate-') as temp:
        root = Path(temp).resolve()
        payload = root / 'payload'
        payload.mkdir(mode=0o700)
        for name, data in files.items():
            (payload / name).write_bytes(data)
        for name, data in inputs.items():
            (root / name).write_bytes(data)
        # Isolated interpreter; only the two byte-pinned modules are importable.
        code = 'import sys,runpy; root=sys.argv.pop(1); sys.path.insert(0,root); sys.argv[0]=root+"/verify_validation_bundle.py"; runpy.run_path(sys.argv[0],run_name="__main__")'
        run = subprocess.run([sys.executable, '-I', '-B', '-c', code, str(root), str(payload), '--denylist', str(root / 'deny.txt')], capture_output=True, timeout=60,
                             cwd=root, env={'PATH': os.defpath, 'LC_ALL': 'C'})
        require(run.returncode == 0, 'Pinned unsigned QA/privacy verifier rejected bundle (details suppressed)')
        require(bundle_files(payload) == files, 'QA verifier changed the accepted payload')


def sign_bundle(args, policy, expected, gpg):
    output = Path(os.path.abspath(args.output))
    parent = directory(output.parent)
    require(not output.exists() and not output.is_symlink(), 'Output already exists; never overwrite')
    files = bundle_files(args.bundle)
    expected_check(files, expected)
    qa_gate(files, expected, args.qa_verifier, args.qa_common, args.denylist, strict_json(regular(args.qa_pins)))
    public_key = regular(args.public_key)
    with tempfile.TemporaryDirectory(prefix='fcv-', dir='/tmp') as temp:
        import_public(gpg, Path(temp).resolve(), public_key, policy, time.time())
    signing_home = directory(args.signing_home)
    require(stat.S_IMODE(signing_home.stat().st_mode) & 0o077 == 0, 'Signing keyring permissions must be private')
    require(bundle_files(args.bundle) == files, 'Source bundle changed during preflight')
    signature_args = ['--local-user', policy['signingFingerprint'] + '!', '--digest-algo', 'SHA256', '--no-textmode', '--output', '-', '--detach-sign']
    attestation_sig = gpg_command(gpg, signing_home, signature_args, pae(PAYLOAD_TYPE, files['validation-attestation.json']))
    checksum_sig = gpg_command(gpg, signing_home, signature_args, files['SHA256SUMS'])
    members = {'payload/' + n: data for n, data in files.items()}
    members.update({'validation-key.asc': public_key, 'SHA256SUMS.gpg': checksum_sig, 'validation.dsse.json': encode({
        'payloadType': PAYLOAD_TYPE, 'payload': base64.b64encode(files['validation-attestation.json']).decode('ascii'),
        'signatures': [{'keyid': policy['signingFingerprint'], 'sig': base64.b64encode(attestation_sig).decode('ascii')}],
    })})
    verify_members(members, policy, expected, gpg)
    # A same-filesystem hard link publishes the completed, reverified archive
    # atomically and fails if ANY destination file/symlink appeared meanwhile.
    with tempfile.TemporaryDirectory(prefix='.validation-output-', dir=parent) as temp:
        archive_path = Path(temp).resolve() / 'bundle.zip'
        with archive_path.open('xb') as archive:
            archive.write(archive_bytes(members))
        verify_members(archive_read(archive_path), policy, expected, gpg)
        os.chmod(archive_path, 0o644)
        with archive_path.open('rb') as stream:
            os.fsync(stream.fileno())
        os.link(archive_path, output)
    return {'status': 'signed-and-verified', 'archiveSha256': sha(regular(output)), 'artifactSha256': expected['artifactSha256']}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action', choices=('sign', 'verify'))
    parser.add_argument('--trust-policy', type=Path, required=True)
    parser.add_argument('--expected', type=Path, required=True)
    parser.add_argument('--gpg', default='gpg')
    parser.add_argument('--archive', type=Path)
    for name in ('bundle', 'output', 'signing-home', 'public-key', 'qa-verifier', 'qa-common', 'denylist', 'qa-pins'):
        parser.add_argument('--' + name, type=Path)
    args = parser.parse_args()
    try:
        gpg = shutil.which(args.gpg)
        require(gpg is not None, 'GnuPG executable unavailable')
        policy, expected = strict_json(regular(args.trust_policy)), strict_json(regular(args.expected))
        if args.action == 'verify':
            require(args.archive is not None, '--archive required')
            result = verify_members(archive_read(args.archive), policy, expected, gpg)
        else:
            require(all(getattr(args, n) is not None for n in ('bundle', 'output', 'signing_home', 'public_key', 'qa_verifier', 'qa_common', 'denylist', 'qa_pins')), 'Explicit signer inputs required')
            result = sign_bundle(args, policy, expected, gpg)
        print(json.dumps(result, sort_keys=True))
        return 0
    except (Invalid, OSError, subprocess.SubprocessError, KeyError, TypeError, RecursionError) as exc:
        # Paths, subprocess stderr, keyring details and rejected document values
        # are never copied into public/operator receipts.
        reason = str(exc) if isinstance(exc, Invalid) else 'Input or tool operation failed'
        print('validation-signing: ' + reason, file=sys.stderr)
        return 2


if __name__ == '__main__':
    raise SystemExit(main())
