You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/20 19:53:12 UTC
(tooling-trusted-releases) branch sbp updated: Properly handle revoked and expired OpenPGP self signatures
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 53bb2564 Properly handle revoked and expired OpenPGP self signatures
53bb2564 is described below
commit 53bb2564bf19ecb54fc5e6ea4ac01ee460852a12
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon Jul 20 20:51:35 2026 +0100
Properly handle revoked and expired OpenPGP self signatures
---
atr/pgp.py | 44 ++++++++++++++++++++++++++++++++++++++------
tests/unit/pgp_fixtures.py | 39 +++++++++++++++++++++++++++++++++++++++
tests/unit/test_pgp.py | 18 ++++++++++++++++++
3 files changed, 95 insertions(+), 6 deletions(-)
diff --git a/atr/pgp.py b/atr/pgp.py
index a99956fe..98587e92 100644
--- a/atr/pgp.py
+++ b/atr/pgp.py
@@ -21,6 +21,10 @@ from typing import Final
import openpgp
+_CERTIFICATION_REVOCATION_SIGNATURE_TYPE: Final[str] = "cert-revocation"
+_CERTIFICATION_SIGNATURE_TYPES: Final[frozenset[str]] = frozenset(
+ {"cert-generic", "cert-persona", "cert-casual", "cert-positive"}
+)
_SUBKEY_BINDING_SIGNATURE_TYPE: Final[str] = "subkey-binding"
@@ -39,7 +43,7 @@ def key_expires_at(key: openpgp.PublicKey) -> datetime.datetime | None:
if effective is None:
return None
key_expiration_seconds = effective.key_expiration_seconds
- if key_expiration_seconds is None:
+ if not key_expiration_seconds:
return None
return datetime.datetime.fromtimestamp(key.created_at + key_expiration_seconds, tz=datetime.UTC)
@@ -90,17 +94,35 @@ def signing_key_status(
return SigningKeyStatus(identified=False, expires=None, can_sign=False)
+def _binding_revoked(binding: openpgp.UserBindingInfo, fingerprint: str, key_id: str) -> bool:
+ candidates = [
+ signature
+ for signature in binding.signatures
+ if _signature_is_self(signature, fingerprint, key_id)
+ and (
+ (signature.signature_type in _CERTIFICATION_SIGNATURE_TYPES)
+ or (signature.signature_type == _CERTIFICATION_REVOCATION_SIGNATURE_TYPE)
+ )
+ ]
+ latest = _latest_signature(candidates)
+ return (latest is not None) and (latest.signature_type == _CERTIFICATION_REVOCATION_SIGNATURE_TYPE)
+
+
def _binding_self_signatures(key: openpgp.PublicKey) -> list[openpgp.SignatureInfo]:
self_fingerprint = key.fingerprint.lower()
self_key_id = key.key_id.lower()
- primary_bindings = [binding for binding in key.user_bindings() if binding.is_primary]
- chosen_bindings = primary_bindings or list(key.user_bindings())
+ active_bindings = [
+ binding for binding in key.user_bindings() if not _binding_revoked(binding, self_fingerprint, self_key_id)
+ ]
+ primary_bindings = [binding for binding in active_bindings if binding.is_primary]
+ chosen_bindings = primary_bindings or active_bindings
binding_sigs: list[openpgp.SignatureInfo] = []
for binding in chosen_bindings:
binding_sigs.extend(
signature
for signature in binding.signatures
if _signature_is_self(signature, self_fingerprint, self_key_id)
+ and (signature.signature_type in _CERTIFICATION_SIGNATURE_TYPES)
)
return binding_sigs
@@ -175,9 +197,19 @@ def _latest_binding_signature(binding: openpgp.SubkeyBindingInfo) -> openpgp.Sig
def _latest_signature(signatures: list[openpgp.SignatureInfo]) -> openpgp.SignatureInfo | None:
- if not signatures:
+ now = datetime.datetime.now(datetime.UTC).timestamp()
+ valid = [signature for signature in signatures if not _signature_expired(signature, now)]
+ if not valid:
return None
- return max(signatures, key=lambda signature: signature.creation_time or 0)
+ return max(valid, key=lambda signature: signature.creation_time or 0)
+
+
+def _signature_expired(signature: openpgp.SignatureInfo, now: float) -> bool:
+ expiration = signature.signature_expiration_seconds
+ if not expiration:
+ return False
+ creation = signature.creation_time or 0
+ return (creation + expiration) <= now
def _signature_is_self(signature: openpgp.SignatureInfo, self_fingerprint: str, self_key_id: str) -> bool:
@@ -193,7 +225,7 @@ def _subkey_expires_at(
if signature is None:
return None
key_expiration_seconds = signature.key_expiration_seconds
- if key_expiration_seconds is None:
+ if not key_expiration_seconds:
return None
# Subkey expiration counts from the subkey's own creation, not the primary's
return datetime.datetime.fromtimestamp(binding.created_at + key_expiration_seconds, tz=datetime.UTC)
diff --git a/tests/unit/pgp_fixtures.py b/tests/unit/pgp_fixtures.py
index 7756e640..38afa213 100644
--- a/tests/unit/pgp_fixtures.py
+++ b/tests/unit/pgp_fixtures.py
@@ -63,6 +63,45 @@ MlyrMMYPGgnWrfquh4sgGRHbuAMzrgA=
"""
+REVOKED_PRIMARY_UID_PUBLIC_KEY_ASC: Final[str] = """-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEal57BBYJKwYBBAHaRw8BAQdATc1jLyFqpUYCaoqbC8xo0ndcHd3mQIyIkpz4
+G1Z/eYu0HVByaW1lIDxwcmltZUBleGFtcGxlLmludmFsaWQ+iHgEMBYIACAWIQTn
+G1wcvBNWtkSXTWqxxyKS7Yt3CgUCal57BgIdIAAKCRCxxyKS7Yt3CmygAQDd1OWS
++om2RO/vkxCTL5p2sGDAYK5SX5F4TKt1Q1ca0wD+JWmwD5CqjtCGwzDMnZYVJYCB
+jWEWzbmVbrsGl/5oRAyIkwQTFggAOwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX
+gBYhBOcbXBy8E1a2RJdNarHHIpLti3cKBQJqXnsFAhkBAAoJELHHIpLti3cKf+EA
+/0kMjkuSmzNSvgWE/TR/Ofxip0G6Zn6ma7ZCHkTgDbA/AQCpBShMqnHW1B+68k+u
+vd432O4O+3Y7YGn9gGfXTrhWBLQfU2Vjb25kIDxzZWNvbmRAZXhhbXBsZS5pbnZh
+bGlkPoiQBBMWCAA4FiEE5xtcHLwTVrZEl01qscciku2LdwoFAmpeewQCGwMFCwkI
+BwIGFQoJCAsCBBYCAwECHgECF4AACgkQscciku2LdwqXSgD/ayqude6JleREnPKN
+KQ4uaPG1F1byPNSYr2TnRpHxiE8BAI52jDFu7urx7YMpiee6HpmD/UDwWd51bf3W
+D67F/4IM
+=UwRL
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+
+REVOKED_UID_PUBLIC_KEY_ASC: Final[str] = """-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEal52nhYJKwYBBAHaRw8BAQdAkkyLnkBjDoWMkWAOZgW/ggtUPB5EYQcFOsnA
+hHDuvGG0HUFsaWNlIDxhbGljZUBleGFtcGxlLmludmFsaWQ+iHgEMBYIACAWIQR2
+OPrERA8xZafQBte7NIWZyART/QUCal52nwIdIAAKCRC7NIWZyART/YFHAQC44c6r
+2aMZVwqthVm3zBPCqFu4x6vuTRwdIHJsgktlPAEArJLY/oIQLZ3w7/eHDSnIZka/
+5c5BZ6IK/m3erALy4QCIkAQTFggAOBYhBHY4+sREDzFlp9AG17s0hZnIBFP9BQJq
+XnaeAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJELs0hZnIBFP9CO4A/1/P
+nK9TnA3eGquBdRYflY4rNsCw3lIeuaM728sGnk1bAQCq4pHwP6j9j2g7q6QI8fgh
+jIB8bZ1m4qJfQr312ZqOBIh0BBEWCAAdFiEEtyjIg/grSedLSOYkjDieBWwwo7cF
+Ampedp4ACgkQjDieBWwwo7cDzwEA47ND7Ze141QwMlk1+SJuS+3KLn4u7W/5hvZZ
+W36enIYA+K/4w4pkDXDdfNX0wxeXdOH3GvgcUzw5UnGAyPLPDwC0IkFsaWNlIFR3
+byA8YWxpY2UyQGV4YW1wbGUuaW52YWxpZD6IkAQTFggAOBYhBHY4+sREDzFlp9AG
+17s0hZnIBFP9BQJqXnaeAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJELs0
+hZnIBFP9OA8A/1t15vvaSJjtPZEKGFT7POk+2Z5Q2krVupn6V9oW4oDpAQDig6j9
+6IrSPtodizFMPPzDPNp6KPOaSiuA6zZshWZEDQ==
+=8ozG
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+
+
def write_expired_subkey_fixture(tmp_path: pathlib.Path) -> tuple[str, str]:
artifact_path = tmp_path / "apache-test-1.0.txt"
signature_path = tmp_path / "apache-test-1.0.txt.asc"
diff --git a/tests/unit/test_pgp.py b/tests/unit/test_pgp.py
index adfae9ee..291e961b 100644
--- a/tests/unit/test_pgp.py
+++ b/tests/unit/test_pgp.py
@@ -23,6 +23,24 @@ import atr.pgp as pgp
import tests.unit.pgp_fixtures as pgp_fixtures
+def test_latest_self_signature_skips_uid_revocations() -> None:
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_UID_PUBLIC_KEY_ASC)
+
+ latest = pgp.latest_self_signature(key)
+
+ assert latest is not None
+ assert latest.signature_type == "cert-positive"
+
+
+def test_latest_self_signature_survives_a_revoked_primary_uid() -> None:
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_PRIMARY_UID_PUBLIC_KEY_ASC)
+
+ latest = pgp.latest_self_signature(key)
+
+ assert latest is not None
+ assert latest.signature_type == "cert-positive"
+
+
def test_signing_key_status_expiry_follows_the_issuing_subkey() -> None:
key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.EXPIRED_SUBKEY_PUBLIC_KEY_ASC)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org