#!/usr/bin/python3
"""sopv-gpgv

OpenPGP Stateless Command Line Interface Verification-Only Subset, backed by gpgv

This implements the sopv 1.0 subset as defined in
https://datatracker.ietf.org/doc/draft-dkg-openpgp-stateless-cli/,
using gpgv as a backend.

Usage:

    sopv-gpgv version [--extended|--backend|--sop-spec|--sopv]

    sopv-gpgv verify [--not-before=DATE] [--not-after=DATE] [--]
        SIGNATURES CERTS [CERTS…] < MESSAGE > VERIFICATIONS

    sopv-gpgv inline-verify [--not-before=DATE] [--not-after=DATE] [--verifications-out=VERIFICATIONS] [--]
        CERTS [CERTS…] < INLINE_SIGNED_MESSAGE > MESSAGE

Author: Daniel Kahn Gillmor <dkg@fifthhorseman.net>
License: MIT

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

"""


import subprocess
import os
import re
import sys
import logging
from argparse import ArgumentParser, RawDescriptionHelpFormatter, Namespace, SUPPRESS
from typing import Optional, Dict, List, Union
from types import ModuleType
from datetime import datetime, UTC
from enum import IntEnum
from io import (
    BytesIO,
    FileIO,
    BufferedReader,
    BufferedIOBase,
    BufferedWriter,
    BufferedRandom,
)
from base64 import b64decode
from tempfile import NamedTemporaryFile, TemporaryFile, _TemporaryFileWrapper

# from typing import List, Optional, Dict, Sequence, MutableMapping, Tuple, BinaryIO, TYPE_CHECKING

argcomplete: Optional[ModuleType]

try:
    import argcomplete
except ImportError:
    argcomplete = None

__version__ = "0.1.4"


class Err(IntEnum):
    OK = 0
    UNSPECIFIED_FAILURE = 1
    NO_SIGNATURE = 3
    UNSUPPORTED_ASYMMETRIC_ALGO = 13
    CERT_CANNOT_ENCRYPT = 17
    MISSING_ARG = 19
    INCOMPLETE_VERIFICATION = 23
    CANNOT_DECRYPT = 29
    PASSWORD_NOT_HUMAN_READABLE = 31
    UNSUPPORTED_OPTION = 37
    BAD_DATA = 41
    EXPECTED_TEXT = 53
    OUTPUT_EXISTS = 59
    MISSING_INPUT = 61
    KEY_IS_PROTECTED = 67
    UNSUPPORTED_SUBCOMMAND = 69
    UNSUPPORTED_SPECIAL_PREFIX = 71
    AMBIGUOUS_INPUT = 73
    KEY_CANNOT_SIGN = 79
    INCOMPATIBLE_OPTIONS = 83
    UNSUPPORTED_PROFILE = 89
    NO_HARDWARE_KEY_FOUND = 97
    HARDWARE_KEY_FAILURE = 101


class SopException(Exception):
    "SOP-specific exception"

    def __init__(self, err: Err, msg: str) -> None:
        super().__init__(msg)
        self.err = err
        self.msg = msg

    def __str__(self) -> str:
        return f"SOP Error {self.err.name} ({int(self.err)}): {self.msg}"


class SigMode(IntEnum):
    BINARY = 0
    TEXT = 1

    def __str__(self) -> str:
        return f"mode:{self.name.lower()}"


class Fingerprint:
    def __init__(self, value: Union[str, bytes]) -> None:
        if isinstance(value, bytes):
            value = value.decode()
        value = value.replace(" ", "")
        if re.match("^[a-fA-F0-9]{40}$", value) is None:
            raise ValueError(f"Bad OpenPGP fingerprint string: {value}")
        self._value = value.upper()

    def __str__(self) -> str:
        return self._value

    def __repr__(self) -> str:
        return f"<Fingerprint: {self._value}>"


def confirm_nonexistence(name: str, err: Err) -> None:
    "raise an exception if a file with this name exists"
    if os.path.exists(name):
        raise SopException(err, f"File '{name}' exists")


class Signatures:
    """Doesn't need to be dearmored, can remain a simple file descriptor"""

    _fd: Optional[int] = None
    _tempfile: Optional[BufferedRandom] = None

    def __init__(self, name: str) -> None:
        self._name = name
        if name.startswith("@FD:"):
            confirm_nonexistence(name, Err.AMBIGUOUS_INPUT)
            self._fd = int(name[4:])
        elif name.startswith("@ENV:"):
            confirm_nonexistence(name, Err.AMBIGUOUS_INPUT)
            self._tempfile = TemporaryFile()
            self._tempfile.write(os.environ[name[5:]].encode())
            self._tempfile.flush()
            self._tempfile.seek(0)
            self._fd = self._tempfile.fileno()
        elif name.startswith("@"):
            raise SopException(
                Err.UNSUPPORTED_SPECIAL_PREFIX,
                f"No implementation for special prefix {name.split(":")[0]}",
            )

    @property
    def fd(self) -> Optional[int]:
        return self._fd

    @property
    def label(self) -> str:
        if self._fd is None:
            return self._name
        return f"-&{self._fd}"


class Certs:
    """Needs to be dearmored, cannot be a simple file descriptor"""

    _armor_matcher = re.compile(
        rb"""# armor header line; capture the variable part of the magic text
        ^-{5}BEGIN\ PGP\ (?P<magic>[A-Z0-9 ,]+)-{5}(?:\r?\n)
        # try to capture all the headers into one capture group
        # if this doesn't match, m['headers'] will be None
        (?P<headers>(^.+:\ .+(?:\r?\n))+)?(?:\r?\n)?
        # capture all lines of the body
        # including the newline, and the pad character(s)
        (?P<body>([A-Za-z0-9+/]+={,2}(?:\r?\n))+)
        # optionally capture the armored CRC24 value
        (?:^=(?P<crc>[A-Za-z0-9+/]{4})(?:\r?\n))?
        # finally, capture the armor tail line, which must match the armor header line
        ^-{5}END\ PGP\ (?P=magic)-{5}(?:\r?\n)?
        """,
        flags=re.MULTILINE | re.VERBOSE,
    )

    def __init__(self, name: str) -> None:
        self._name = name
        _rawfile: Union[BufferedReader, _TemporaryFileWrapper[bytes]]
        if name.startswith("@FD:"):
            confirm_nonexistence(name, Err.AMBIGUOUS_INPUT)
            _rawfile = NamedTemporaryFile()
            _rawfile.write(BufferedReader(FileIO(int(name[4:]), mode="rb")).read())
        elif name.startswith("@ENV:"):
            confirm_nonexistence(name, Err.AMBIGUOUS_INPUT)
            _rawfile = NamedTemporaryFile()
            _rawfile.write(os.environ[name[5:]].encode())
        elif name.startswith("@"):
            raise SopException(
                Err.UNSUPPORTED_SPECIAL_PREFIX,
                f"No implementation for special prefix {name.split(":")[0]}",
            )
        else:
            _rawfile = open(name, "rb")

        _rawfile.seek(0)
        early = _rawfile.peek(15)
        if early.startswith(b"-----BEGIN PGP "):
            data = _rawfile.read()
            _rawfile = NamedTemporaryFile()
            _rawfile.write(self.unarmor(data))
        self._file = _rawfile
        self._file.flush()

    def unarmor(self, data: bytes) -> bytes:
        m = self._armor_matcher.search(data)
        if m is None or m.groupdict()["body"] is None:
            raise SopException(Err.BAD_DATA, "Could not dearmor")
        body = m.groupdict()["body"]
        return b64decode(body)

    @property
    def name(self) -> str:
        'work around gpgv bizarre expectation about file names with no "/" being found in the GnuPG homedir'
        if "/" not in self._file.name:
            return "./" + self._file.name
        return self._file.name


class Verification:
    """- ISO-8601 UTC datestamp of the signature, to one second precision, using the `Z` suffix
    - Fingerprint of the signing key (may be a subkey)
    - Fingerprint of primary key of signing certificate (if signed by primary key, same as the previous field)
    - a string describing the mode of the signature, either `mode:text` or `mode:binary`
    - message describing the verification (free form)
    """

    def __init__(self) -> None:
        self.ts: Optional[datetime] = None
        self.signingfpr: Optional[Fingerprint] = None
        self.primaryfpr: Optional[Fingerprint] = None
        self.mode: Optional[SigMode] = None
        self.msg: Optional[str] = None
        self.good: bool = False
        self.key_expired: Optional[datetime] = None

    @property
    def complete(self) -> bool:
        return (
            self.ts is not None
            and (
                self.good
                or (self.key_expired is not None and self.key_expired > self.ts)
            )
            and self.signingfpr is not None
            and self.primaryfpr is not None
            and self.mode is not None
            and self.ts.tzinfo == UTC
        )

    def __str__(self) -> str:
        if not self.complete or self.ts is None:
            raise ValueError("Verification Object is not complete")
        msg = ""
        if self.msg is not None:
            msg = f" {self.msg}"
        return f"{self.ts.strftime('%Y-%m-%dT%H:%m:%SZ')} {self.signingfpr} {self.primaryfpr} {self.mode}{msg}"

    @staticmethod
    def from_gpg_status_ts(part: bytes) -> datetime:
        if b"T" in part:
            return datetime.fromisoformat(part.decode())
        else:
            return datetime.fromtimestamp(int(part.decode()), UTC)


class GpgvSopv:
    _parser: Optional[ArgumentParser] = None
    _gpgv: str = os.environ.get("SOPV_GPGV", "gpgv")

    def timestamp(self, instr: str) -> Optional[datetime]:
        if instr == "-":
            return None
        elif instr == "now":
            return datetime.now(tz=UTC)
        else:
            return datetime.fromisoformat(instr)

    def indirect_output(self, outputname: str) -> BufferedWriter:
        if outputname.startswith("@FD:"):
            return BufferedWriter(FileIO(int(outputname[4:]), "wb"))
        elif outputname.startswith("@"):
            raise SopException(
                Err.UNSUPPORTED_SPECIAL_PREFIX,
                f"No implementation for special prefix {outputname.split(":")[0]}",
            )
        else:
            confirm_nonexistence(outputname, Err.OUTPUT_EXISTS)
            return BufferedWriter(FileIO(outputname, "wb"))

    @property
    def parser(self) -> ArgumentParser:
        if self._parser is None:
            self._parser = ArgumentParser(
                prog="sopv-gpgv",
                description="Verify OpenPGP signatures using gpgv",
                formatter_class=RawDescriptionHelpFormatter,
                epilog="""This tool implements `sopv`, the verificaftion-only subset of
https://datatracker.ietf.org/doc/draft-dkg-openpgp-stateless-cli/
            
It uses gpgv (from the GnuPG project) as its backend.

The environment variable $SOPV_GPGV can be used to choose the name or path of the gpgv
implementation used (defaults to "gpgv").""",
                allow_abbrev=False,
            )

            self._parser.add_argument(
                "--debug",
                help="Provide debugging information",
                action="store_true",
            )

            _cmds = self._parser.add_subparsers(
                title="Exactly one subcommand must be selected",
                required=True,
                metavar="SUBCOMMAND",
                dest="subcommand",
            )

            _debug = ArgumentParser(add_help=False)
            _debug.add_argument(
                "--debug",
                help="Provide debugging information",
                default=SUPPRESS,
                action="store_true",
            )

            _version = _cmds.add_parser(
                "version",
                help="emit version",
                parents=[_debug],
                description="Print version information to stdout",
            )
            _version_type = _version.add_mutually_exclusive_group(required=False)
            _version_type.add_argument(
                "--backend",
                help="Show gpgv version",
                action="store_true",
            )
            _version_type.add_argument(
                "--extended",
                help="Show extended version information",
                action="store_true",
            )
            _version_type.add_argument(
                "--sop-spec",
                help="Show last known version of SOP, the Stateless OpenPGP specification",
                action="store_true",
            )
            _version_type.add_argument(
                "--sopv",
                help="Show compliant version of the `sopv` verification only subset of SOP",
                action="store_true",
            )
            _version.set_defaults(func=self.version)

            _date_range = ArgumentParser(add_help=False, parents=[_debug])
            _date_range.add_argument(
                "--not-before",
                type=self.timestamp,
                help="Only accept signatures later than this (default: None)",
                default=None,
                metavar="TIMESTAMP",
            )
            _date_range.add_argument(
                "--not-after",
                type=self.timestamp,
                help="Only accept signatures earlier than this (default: now)",
                default=datetime.now(UTC),
                metavar="TIMESTAMP",
            )

            _verify = _cmds.add_parser(
                "verify",
                help="verify detached signatures",
                parents=[_date_range],
                description="Verify detached OpenPGP signatures",
                formatter_class=RawDescriptionHelpFormatter,
                epilog=f"""input/output:

   STDIN: Message to verify
  STDOUT: SOP-style VERIFICATIONS (if any)

return value:

0 if at least one valid signature from an acceptable certificate, non-zero otherwise.""",
            )
            _verify.add_argument(
                "SIGNATURES",
                type=Signatures,
                help="OpenPGP Signatures",
            )
            _verify.add_argument(
                "CERTS",
                type=Certs,
                help="Acceptable OpenPGP Certificates for Signature Verification",
                nargs="+",
            )
            _verify.set_defaults(func=self.verify)

            _inline_verify = _cmds.add_parser(
                "inline-verify",
                help="verify inline signatures",
                parents=[_date_range],
                description='Verify inline OpenPGP signatures (either CSF or "Signed Message" OpenPGP packets)',
                formatter_class=RawDescriptionHelpFormatter,
                epilog="""input/output:

   STDIN: Inline-signed OpenPGP message to verify
  STDOUT: If correctly signed, message without OpenPGP signature

return value:

0 if at least one valid signature from an acceptable certificate, non-zero otherwise.""",
            )
            _inline_verify.add_argument(
                "--verifications-out",
                type=self.indirect_output,
                help="Write SOP-style VERIFICATIONS here",
                metavar="VERIFICATIONS",
            )
            _inline_verify.add_argument(
                "CERTS",
                type=Certs,
                help="Acceptable OpenPGP Certificates for Signature Verification",
                nargs="+",
            )
            _inline_verify.set_defaults(func=self.inline_verify)

        return self._parser

    def get_gpgv_version(self) -> str:
        success = subprocess.run(
            [self._gpgv, "--version"],
            capture_output=True,
        )
        return success.stdout.decode()

    def version(self, args: Namespace) -> None:
        if args.backend:
            print(f"gpgv {self.get_gpgv_version().split('\n')[0].split(' ')[-1]}")
        elif args.extended:
            print(f"sopv-gpgv {__version__}\n{self.get_gpgv_version().strip()}")
        elif args.sop_spec:
            print("~draft-dkg-openpgp-stateless-cli-10")
        elif args.sopv:
            print("1.0")
        else:
            print(f"sopv-gpgv {__version__}")

    def status_to_verifs(
        self,
        status: bytes,
        not_before: Optional[datetime],
        not_after: Optional[datetime],
    ) -> List[Verification]:
        """From DETAILS.gz, each NEWSIG status line delimits the evaluation of another signature.
        We are looking for a signature that emits both GOODSIG and VALIDSIG.

        We get the data from the associated VALIDSIG entry.
        """
        verif = Verification()
        verifs: List[Verification] = []
        for l in status.split(b"\n"):
            if l == b"[GNUPG:] NEWSIG":
                if verif.complete:
                    verifs.append(verif)
                verif = Verification()
                continue
            if l.startswith(b"[GNUPG:] GOODSIG "):
                verif.good = True
            if l.startswith(b"[GNUPG:] KEYEXPIRED "):
                parts = l.split(b" ")
                verif.key_expired = Verification.from_gpg_status_ts(parts[2])
            if l.startswith(b"[GNUPG:] VALIDSIG "):
                parts = l.split(b" ")
                ts = Verification.from_gpg_status_ts(parts[4])
                if not_before is not None and ts < not_before:
                    continue
                if not_after is not None and ts > not_after:
                    continue
                verif.ts = ts
                verif.signingfpr = Fingerprint(parts[2].decode())
                verif.primaryfpr = Fingerprint(parts[11].decode())
                verif.mode = SigMode(int(parts[10]))
        if verif.complete:
            verifs.append(verif)
        return verifs

    def verify(self, args: Namespace) -> None:
        sigs: Signatures = args.SIGNATURES
        certs: List[Certs] = args.CERTS
        status = TemporaryFile()

        cmd = [
            self._gpgv,
            "--enable-special-filenames",
            "--homedir=/dev/null",
            f"--status-fd={status.fileno()}",
        ]
        # add each certs as a --keyring argument -- gpgv does not accept -& arguments for keyrings:
        # https://dev.gnupg.org/T4608
        for cert in certs:
            cmd += [f"--keyring={cert.name}"]
            logging.info(f"cert({cert._name}): {cert.name}")
        cmd += [
            "--",
            sigs.label,
            "-",
        ]
        keep_fds = [0, status.fileno()]
        if sigs.fd is not None:
            keep_fds += [sigs.fd]
        res = subprocess.run(
            cmd,
            capture_output=True,
            pass_fds=keep_fds,
        )
        status.seek(0)
        status_data = status.read()
        verifs = self.status_to_verifs(status_data, args.not_before, args.not_after)
        # we ignore returncode, because gpgv fails if any signature fails.
        if len(verifs) == 0:
            raise SopException(Err.NO_SIGNATURE, "No Valid Signature found")
        for verif in verifs:
            print(verif)

    def inline_verify(self, args: Namespace) -> None:
        certs: List[Certs] = args.CERTS
        status = TemporaryFile()
        output = TemporaryFile()

        cmd = [
            self._gpgv,
            "--enable-special-filenames",
            "--homedir=/dev/null",
            f"--status-fd={status.fileno()}",
            f"--output=-&{output.fileno()}",
        ]
        # add each certs as a --keyring argument -- gpgv does not accept -& arguments for keyrings:
        # https://dev.gnupg.org/T4608
        for cert in certs:
            cmd += [f"--keyring={cert.name}"]
            logging.info(f"cert({cert._name}): {cert.name}")
        cmd += [
            "--",
            "-",
        ]
        keep_fds = [0, status.fileno(), output.fileno()]
        res = subprocess.run(
            cmd,
            capture_output=True,
            pass_fds=keep_fds,
        )
        status.seek(0)
        status_data = status.read()
        verifs = self.status_to_verifs(status_data, args.not_before, args.not_after)
        if len(verifs) == 0:
            raise SopException(Err.NO_SIGNATURE, "No Valid Inline Signature Found")
        else:
            output.seek(0)
            # write message to stdout
            sys.stdout.buffer.write(output.read())
            if args.verifications_out is not None:
                for verif in verifs:
                    args.verifications_out.write((str(verif) + "\n").encode())


def get_parser() -> ArgumentParser:
    "This function is used by argparse-manpage to extract a manpage"
    return GpgvSopv().parser


def main() -> None:
    sopv = GpgvSopv()
    if argcomplete:
        argcomplete.autocomplete(sopv.parser)
    elif "_ARGCOMPLETE" in os.environ:
        logging.error(
            'Argument completion requested but the "argcomplete" module is not installed.'
            "It can be obtained at https://pypi.python.org/pypi/argcomplete"
        )
        sys.exit(1)
    try:
        args = sopv.parser.parse_args(sys.argv[1:])
        if args.debug:
            logging.basicConfig(level=logging.DEBUG)
        logging.debug(f"Subcommand: {args.subcommand}")
        args.func(args)
    except SopException as e:
        logging.error(e)
        sys.exit(e.err)


if __name__ == "__main__":
    main()
    sys.exit(0)
