312 lines
10 KiB
Python
312 lines
10 KiB
Python
#!/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)
|