#!/usr/bin/python3 # auth - authentication key management # # Copyright (c) 2004 Canonical # Copyright (c) 2012 Sebastian Heinlein # # Author: Michael Vogt # Sebastian Heinlein # # 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 ") lambda: _("Ubuntu CD Image Automatic Signing Key ") apt_pkg.init() for trusted_key in list_keys(): print(trusted_key)