Source code for repoman.gpg
"""GPG key fetch and verification helpers. No GTK — fully unit-testable."""
from __future__ import annotations
import logging
import subprocess
from base64 import b64encode
from pathlib import Path
import requests
_log = logging.getLogger(__name__)
_PPA_HOSTS = {"ppa.launchpadcontent.net", "ppa.launchpad.net"}
_KEYSERVER_FETCH = "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{fingerprint}"
[docs]
def is_ppa_uri(uri: str) -> bool:
"""Return True if ``uri`` points at a Launchpad PPA host.
:param uri: Repository URI to test.
:type uri: str
:returns: True for ``ppa.launchpadcontent.net`` and ``ppa.launchpad.net``.
:rtype: bool
"""
from urllib.parse import urlparse
return urlparse(uri).netloc in _PPA_HOSTS
[docs]
def fetch_key(url: str) -> tuple[bytes | None, str | None]:
"""Download a GPG key from a URL. Returns (key_bytes, error_str)."""
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
return resp.content, None
except requests.RequestException as exc:
_log.debug("fetch_key failed for %s", url, exc_info=True)
return None, str(exc)
[docs]
def fetch_ppa_key(owner: str, ppa: str) -> tuple[bytes | None, str | None]:
"""Fetch a PPA signing key via launchpadlib. Returns (key_bytes, error_str)."""
try:
from launchpadlib.launchpad import Launchpad
lp = Launchpad.login_anonymously("repoman", "production", version="devel")
archive = lp.people[owner].getPPAByName(name=ppa)
fingerprint = archive.signing_key_fingerprint
if not fingerprint:
return None, "No signing key found on Launchpad"
url = _KEYSERVER_FETCH.format(fingerprint=fingerprint)
return fetch_key(url)
except Exception as exc:
_log.debug("fetch_ppa_key failed for %s/%s", owner, ppa, exc_info=True)
return None, str(exc)
[docs]
def verify_key(content: str | bytes) -> tuple[bool, str]:
"""Verify GPG key via gpg --import-options show-only. Returns (valid, error)."""
data = content.encode() if isinstance(content, str) else content
try:
result = subprocess.run(
["/usr/bin/gpg", "--batch", "--import-options", "show-only", "--import"],
input=data,
capture_output=True,
)
if result.returncode != 0:
return False, result.stderr.decode(errors="replace").strip()
return True, ""
except OSError as exc:
return False, str(exc)
[docs]
def key_to_b64(key_bytes: bytes) -> str:
"""Base64-encode key bytes for transmission in a JSON polkit payload."""
return b64encode(key_bytes).decode("ascii")
[docs]
def read_key_text(path: Path) -> str | None:
"""Read a key file as ASCII-armored text, or None if binary/missing."""
try:
text = path.read_text(encoding="ascii")
if "BEGIN PGP" in text:
return text
except (OSError, UnicodeDecodeError):
pass
return None
[docs]
def read_key_content(path: Path) -> str | None:
"""Read a keyring file as displayable ASCII text.
ASCII-armored files are returned directly. Binary (dearmored) files
are converted via gpg --enarmor so they can be shown in the editor.
Returns None if the file is missing, empty, or conversion fails.
"""
try:
raw = path.read_bytes()
except OSError:
return None
if not raw:
return None
if raw.lstrip().startswith(b"-----BEGIN PGP"):
return raw.decode("ascii", errors="replace")
try:
result = subprocess.run(
["/usr/bin/gpg", "--batch", "--enarmor"],
input=raw,
capture_output=True,
)
if result.returncode == 0 and result.stdout:
return result.stdout.decode("ascii", errors="replace")
except OSError:
pass
return None