#!/usr/bin/python3
# auth - authentication key management
#
#  Copyright (c) 2004 Canonical
#  Copyright (c) 2012 Sebastian Heinlein
#
#  Author: Michael Vogt <mvo@debian.org>
#          Sebastian Heinlein <devel@glatzor.de>
#
#  This program is free software; you can redistribute it and/or
#  modify it under the terms of the GNU General Public License as
#  published by the Free Software Foundation; either version 2 of the
#  License, or (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
#  USA
"""Handle GnuPG keys used to trust signed repositories."""

import errno
import os
import os.path
import shutil
import subprocess
import sys
import tempfile

import apt_pkg
from apt_pkg import gettext as _


class AptKeyError(Exception):
    pass


class AptKeyIDTooShortError(AptKeyError):
    """Internal class do not rely on it."""


class TrustedKey:

    """Represents a trusted key."""

    def __init__(self, name: str, keyid: str, date: str) -> None:
        self.raw_name = name
        # Allow to translated some known keys
        self.name = _(name)
        self.keyid = keyid
        self.date = date

    def __str__(self) -> str:
        return f"{self.name}\n{self.keyid} {self.date}"


def _call_apt_key_script(*args: str, **kwargs: str | None) -> str:
    """Run the apt-key script with the given arguments."""
    conf = None
    cmd = [apt_pkg.config.find_file("Dir::Bin::Apt-Key", "/usr/bin/apt-key")]
    cmd.extend(args)
    env = os.environ.copy()
    env["LANG"] = "C"
    env["APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE"] = "1"
    try:
        if apt_pkg.config.find_dir("Dir") != "/":
            # If the key is to be installed into a chroot we have to export the
            # configuration from the chroot to the apt-key script by using
            # a temporary APT_CONFIG file. The apt-key script uses apt-config
            # shell internally
            conf = tempfile.NamedTemporaryFile(prefix="apt-key", suffix=".conf")
            conf.write(apt_pkg.config.dump().encode("UTF-8"))
            conf.flush()
            env["APT_CONFIG"] = conf.name
        proc = subprocess.Popen(
            cmd,
            env=env,
            universal_newlines=True,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )

        stdin = kwargs.get("stdin", None)

        output, stderr = proc.communicate(stdin)  # type: str, str

        if proc.returncode:
            raise AptKeyError(
                "The apt-key script failed with return code %s:\n"
                "%s\n"
                "stdout: %s\n"
                "stderr: %s" % (proc.returncode, " ".join(cmd), output, stderr)
            )
        elif stderr:
            sys.stderr.write(stderr)  # Forward stderr

        return output.strip()
    finally:
        if conf is not None:
            conf.close()


def add_key_from_file(filename: str) -> None:
    """Import a GnuPG key file to trust repositores signed by it.

    Keyword arguments:
    filename -- the absolute path to the public GnuPG key file
    """
    if not os.path.abspath(filename):
        raise AptKeyError("An absolute path is required: %s" % filename)
    if not os.access(filename, os.R_OK):
        raise AptKeyError("Key file cannot be accessed: %s" % filename)
    _call_apt_key_script("add", filename)


def add_key_from_keyserver(keyid: str, keyserver: str) -> None:
    """Import a GnuPG key file to trust repositores signed by it.

    Keyword arguments:
    keyid -- the long keyid (fingerprint) of the key, e.g.
             A1BD8E9D78F7FE5C3E65D8AF8B48AD6246925553
    keyserver -- the URL or hostname of the key server
    """
    tmp_keyring_dir = tempfile.mkdtemp()
    try:
        _add_key_from_keyserver(keyid, keyserver, tmp_keyring_dir)
    except Exception:
        raise
    finally:
        # We are racing with gpg when removing sockets, so ignore
        # failure to delete non-existing files.
        def onerror(
            func: object, path: str, exc_info: tuple[type, Exception, object]
        ) -> None:
            if isinstance(exc_info[1], OSError) and exc_info[1].errno == errno.ENOENT:
                return
            raise

        shutil.rmtree(tmp_keyring_dir, onerror=onerror)


def _add_key_from_keyserver(keyid: str, keyserver: str, tmp_keyring_dir: str) -> None:
    if len(keyid.replace(" ", "").replace("0x", "")) < (160 / 4):
        raise AptKeyIDTooShortError("Only fingerprints (v4, 160bit) are supported")
    # create a temp keyring dir
    tmp_secret_keyring = os.path.join(tmp_keyring_dir, "secring.gpg")
    tmp_keyring = os.path.join(tmp_keyring_dir, "pubring.gpg")
    # default options for gpg
    gpg_default_options = [
        "gpg",
        "--no-default-keyring",
        "--no-options",
        "--homedir",
        tmp_keyring_dir,
    ]
    # download the key to a temp keyring first
    res = subprocess.call(
        gpg_default_options
        + [
            "--secret-keyring",
            tmp_secret_keyring,
            "--keyring",
            tmp_keyring,
            "--keyserver",
            keyserver,
            "--recv",
            keyid,
        ]
    )
    if res != 0:
        raise AptKeyError(f"recv from '{keyserver}' failed for '{keyid}'")
    # FIXME:
    # - with gnupg 1.4.18 the downloaded key is actually checked(!),
    #   i.e. gnupg will not import anything that the server sends
    #   into the keyring, so the below checks are now redundant *if*
    #   gnupg 1.4.18 is used

    # now export again using the long key id (to ensure that there is
    # really only this one key in our keyring) and not someone MITM us
    tmp_export_keyring = os.path.join(tmp_keyring_dir, "export-keyring.gpg")
    res = subprocess.call(
        gpg_default_options
        + [
            "--keyring",
            tmp_keyring,
            "--output",
            tmp_export_keyring,
            "--export",
            keyid,
        ]
    )
    if res != 0:
        raise AptKeyError("export of '%s' failed", keyid)
    # now verify the fingerprint, this is probably redundant as we
    # exported by the fingerprint in the previous command but its
    # still good paranoia
    output = subprocess.Popen(
        gpg_default_options
        + [
            "--keyring",
            tmp_export_keyring,
            "--fingerprint",
            "--batch",
            "--fixed-list-mode",
            "--with-colons",
        ],
        stdout=subprocess.PIPE,
        universal_newlines=True,
    ).communicate()[0]
    got_fingerprint = None
    for line in output.splitlines():
        if line.startswith("fpr:"):
            got_fingerprint = line.split(":")[9]
            # stop after the first to ensure no subkey trickery
            break
    # strip the leading "0x" is there is one and uppercase (as this is
    # what gnupg is using)
    signing_key_fingerprint = keyid.replace("0x", "").upper()
    if got_fingerprint != signing_key_fingerprint:
        # make the error match what gnupg >= 1.4.18 will output when
        # it checks the key itself before importing it
        raise AptKeyError(
            f"recv from '{keyserver}' failed for '{signing_key_fingerprint}'"
        )
    # finally add it
    add_key_from_file(tmp_export_keyring)


def add_key(content: str) -> None:
    """Import a GnuPG key to trust repositores signed by it.

    Keyword arguments:
    content -- the content of the GnuPG public key
    """
    _call_apt_key_script("adv", "--quiet", "--batch", "--import", "-", stdin=content)


def remove_key(fingerprint: str) -> None:
    """Remove a GnuPG key to no longer trust repositores signed by it.

    Keyword arguments:
    fingerprint -- the fingerprint identifying the key
    """
    _call_apt_key_script("rm", fingerprint)


def export_key(fingerprint: str) -> str:
    """Return the GnuPG key in text format.

    Keyword arguments:
    fingerprint -- the fingerprint identifying the key
    """
    return _call_apt_key_script("export", fingerprint)


def update() -> str:
    """Update the local keyring with the archive keyring and remove from
    the local keyring the archive keys which are no longer valid. The
    archive keyring is shipped in the archive-keyring package of your
    distribution, e.g. the debian-archive-keyring package in Debian.
    """
    return _call_apt_key_script("update")


def net_update() -> str:
    """Work similar to the update command above, but get the archive
    keyring from an URI instead and validate it against a master key.
    This requires an installed wget(1) and an APT build configured to
    have a server to fetch from and a master keyring to validate. APT
    in Debian does not support this command and relies on update
    instead, but Ubuntu's APT does.
    """
    return _call_apt_key_script("net-update")


def list_keys() -> list[TrustedKey]:
    """Returns a list of TrustedKey instances for each key which is
    used to trust repositories.
    """
    # The output of `apt-key list` is difficult to parse since the
    # --with-colons parameter isn't user
    output = _call_apt_key_script(
        "adv", "--with-colons", "--batch", "--fixed-list-mode", "--list-keys"
    )
    res = []
    for line in output.split("\n"):
        fields = line.split(":")
        if fields[0] == "pub":
            keyid = fields[4]
        if fields[0] == "uid":
            uid = fields[9]
            creation_date = fields[5]
            key = TrustedKey(uid, keyid, creation_date)
            res.append(key)
    return res


if __name__ == "__main__":
    # Add some known keys we would like to see translated so that they get
    # picked up by gettext
    lambda: _("Ubuntu Archive Automatic Signing Key <ftpmaster@ubuntu.com>")
    lambda: _("Ubuntu CD Image Automatic Signing Key <cdimage@ubuntu.com>")

    apt_pkg.init()
    for trusted_key in list_keys():
        print(trusted_key)
