#!/usr/bin/env python3
"""Verify a Seven Bonds signed manifest and its referenced files (offline)."""
import argparse
import base64
import binascii
import hashlib
import json
import re
import sys
from pathlib import Path, PurePosixPath

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa

SCHEMA = 'seven-bonds-signature-v1'
SIGNATURE_SPEC = {
    'algorithm': 'RSA-PSS',
    'hash': 'SHA-256',
    'mgf': 'MGF1',
    'mgf_hash': 'SHA-256',
    'salt_length': 'maximum',
    'encoding': 'base64url-no-padding',
    'signed_content': 'exact-file-bytes',
}
ADDRESS_RE = re.compile(r'[A-Za-z0-9_-]{43}\Z')
SHA_RE = re.compile(r'[0-9a-f]{64}\Z')
B64_RE = re.compile(r'[A-Za-z0-9_-]+\Z')


def fail(message):
    raise ValueError(message)


def b64decode(value):
    if not isinstance(value, str) or not B64_RE.fullmatch(value):
        fail('invalid unpadded base64url value')
    try:
        result = base64.b64decode(value + '=' * (-len(value) % 4), altchars=b'-_', validate=True)
    except (binascii.Error, ValueError):
        fail('invalid base64url encoding')
    if base64.urlsafe_b64encode(result).decode().rstrip('=') != value:
        fail('noncanonical base64url encoding')
    return result


def b64encode(value):
    return base64.urlsafe_b64encode(value).decode().rstrip('=')


def read_json(path):
    data = json.loads(path.read_text(encoding='utf-8'))
    if not isinstance(data, dict):
        fail(f'{path.name}: expected JSON object')
    return data


def safe_file(base, relative):
    if not isinstance(relative, str) or not relative or '\\' in relative or '\x00' in relative:
        fail('invalid file path')
    path = PurePosixPath(relative)
    if path.is_absolute() or any(part in ('.', '..') for part in relative.split('/')) or '//' in relative or relative.endswith('/') or ':' in relative:
        fail(f'unsafe file path: {relative}')
    base = base.resolve(strict=True)
    candidate = base.joinpath(*path.parts)
    if not candidate.is_file():
        fail(f'missing or non-regular file: {relative}')
    # Resolving follows symlinks. Reject symlinks altogether for portable packages.
    if any(p.is_symlink() for p in [candidate, *list(candidate.parents)[:len(path.parts)-1]]):
        fail(f'symlink not allowed: {relative}')
    if not candidate.resolve(strict=True).is_relative_to(base):
        fail(f'file escapes publication directory: {relative}')
    return candidate


def sha256_file(path):
    digest = hashlib.sha256()
    with path.open('rb') as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b''):
            digest.update(block)
    return digest.hexdigest()


def verify(manifest_path, trusted_address):
    if trusted_address is not None and not ADDRESS_RE.fullmatch(trusted_address):
        fail('expected a 43-character trusted Arweave address')
    manifest_path = manifest_path.resolve(strict=True)
    if not manifest_path.is_file():
        fail('manifest is not a regular file')
    manifest_bytes = manifest_path.read_bytes()
    manifest = json.loads(manifest_bytes)
    if not isinstance(manifest, dict):
        fail('manifest must be a JSON object')
    public_path = Path(str(manifest_path) + '.public.json')
    signature_path = Path(str(manifest_path) + '.sig')
    public = read_json(public_path)
    if public.get('schema') != SCHEMA:
        fail('unsupported signature schema')
    spec = public.get('signature')
    if not isinstance(spec, dict):
        fail('missing signature specification')
    for key, value in SIGNATURE_SPEC.items():
        if spec.get(key) != value:
            fail(f'unsupported signature specification: {key}')
    if spec.get('signed_file') != manifest_path.name or spec.get('signature_file') != signature_path.name:
        fail('signature filenames do not match')
    if public.get('e') is None or public.get('n') is None:
        fail('missing public RSA key')
    modulus = b64decode(public['n'])
    exponent = b64decode(public['e'])
    if len(modulus) != 512:
        fail('expected a 4096-bit Arweave RSA modulus')
    address = b64encode(hashlib.sha256(modulus).digest())
    if public.get('address') != address:
        fail('public-key address mismatch')
    if trusted_address is not None and address != trusted_address:
        fail('address does not match independently trusted address')
    if manifest.get('publisher_address') != address:
        fail('manifest publisher_address mismatch')
    try:
        key = rsa.RSAPublicNumbers(int.from_bytes(exponent, 'big'), int.from_bytes(modulus, 'big')).public_key()
    except ValueError as exc:
        fail(f'invalid RSA public key: {exc}')
    signature = b64decode(signature_path.read_text(encoding='ascii').strip())
    try:
        key.verify(signature, manifest_bytes, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH), hashes.SHA256())
    except InvalidSignature:
        fail('invalid manifest signature')
    files = manifest.get('files')
    if not isinstance(files, list) or not files:
        fail('manifest files must be a nonempty list')
    seen = set()
    for entry in files:
        if not isinstance(entry, dict):
            fail('invalid files entry')
        name, expected = entry.get('path'), entry.get('sha256')
        if not isinstance(name, str) or name in seen:
            fail(f'duplicate or invalid file path: {name}')
        seen.add(name)
        if not isinstance(expected, str) or not SHA_RE.fullmatch(expected):
            fail(f'invalid SHA-256 for {name}')
        path = safe_file(manifest_path.parent, name)
        if sha256_file(path) != expected:
            fail(f'file SHA-256 mismatch: {name}')
    print('VERIFIED: manifest signature and all referenced file hashes')
    print('Wallet:', address)
    print('Manifest SHA-256:', hashlib.sha256(manifest_bytes).hexdigest())
    print('Files verified:', len(files))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('manifest', type=Path, help='path to manifest.json')
    parser.add_argument('--trusted-address', default=None, help='optional independently trusted Arweave publication address')
    args = parser.parse_args()
    try:
        verify(args.manifest, args.trusted_address)
    except (ValueError, OSError, json.JSONDecodeError, UnicodeError) as exc:
        print(f'FAILED: {exc}', file=sys.stderr)
        return 1
    return 0


if __name__ == '__main__':
    sys.exit(main())
