You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/07/17 16:34:37 UTC
(tooling-trusted-releases) branch arm updated (69d6057d -> b05eecfd)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard 69d6057d #1417 - Reject on revoked
new b05eecfd #1417 - Reject on revoked
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (69d6057d)
\
N -- N -- N refs/heads/arm (b05eecfd)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../test_files/Test_0x8276EBE049B52B73_public.asc | 30 ----------------------
.../Test_4096_0x27F0153680F65BB2_public.asc | 16 ------------
.../Test_4096_0xD9F624568BE42DC2_public.asc | 16 ------------
3 files changed, 62 deletions(-)
delete mode 100644 tests/e2e/test_files/Test_0x8276EBE049B52B73_public.asc
delete mode 100644 tests/e2e/test_files/Test_4096_0x27F0153680F65BB2_public.asc
delete mode 100644 tests/e2e/test_files/Test_4096_0xD9F624568BE42DC2_public.asc
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #1417 - Reject on revoked
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit b05eecfd934ccf1d0914d51a7cc7faa36ed29a77
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Fri Jul 17 17:17:14 2026 +0100
#1417 - Reject on revoked
---
atr/pgp.py | 30 ++++++++++-
atr/post/keys.py | 4 ++
atr/storage/datatypes.py | 2 +
atr/storage/writers/keys.py | 67 +++++++++++++++++++++--
atr/tasks/checks/__init__.py | 18 +++++--
atr/tasks/checks/signature.py | 35 +++++++++++-
tests/unit/pgp_fixtures.py | 105 ++++++++++++++++++++++++++++++++++++
tests/unit/test_checks_signature.py | 16 ++++++
tests/unit/test_keys_delete.py | 38 ++++++++++++-
tests/unit/test_pgp.py | 54 +++++++++++++++++++
10 files changed, 359 insertions(+), 10 deletions(-)
diff --git a/atr/pgp.py b/atr/pgp.py
index a99956fe..bd93c018 100644
--- a/atr/pgp.py
+++ b/atr/pgp.py
@@ -21,7 +21,14 @@ from typing import Final
import openpgp
+# The self-certifications which carry key expiry and capabilities. A revocation sits alongside these
+# on a user binding, but declares neither, so it can't stand in for one
+_CERTIFICATION_SIGNATURE_TYPES: Final[frozenset[str]] = frozenset(
+ {"cert-generic", "cert-persona", "cert-casual", "cert-positive"}
+)
+_KEY_REVOCATION_SIGNATURE_TYPE: Final[str] = "key-revocation"
_SUBKEY_BINDING_SIGNATURE_TYPE: Final[str] = "subkey-binding"
+_SUBKEY_REVOCATION_SIGNATURE_TYPE: Final[str] = "subkey-revocation"
@dataclasses.dataclass(frozen=True)
@@ -32,6 +39,8 @@ class SigningKeyStatus:
expires: datetime.datetime | None
# False only when the issuing key declares capabilities which exclude signing
can_sign: bool
+ # True where the issuing key carries a revocation, or hangs beneath a primary which does
+ revoked: bool
def key_expires_at(key: openpgp.PublicKey) -> datetime.datetime | None:
@@ -63,6 +72,7 @@ def signing_key_status(
issuer_key_ids: set[str],
) -> SigningKeyStatus:
"""Validity of the key which issued a signature, be that the primary key or one of its subkeys."""
+ primary_revoked = _primary_is_revoked(key)
binding = _issuing_subkey_binding(key, issuer_fingerprints, issuer_key_ids)
if binding is not None:
# A subkey is only usable while its own binding and the primary above it both hold
@@ -76,6 +86,8 @@ def signing_key_status(
identified=True,
expires=min(expirations) if expirations else None,
can_sign=_declares_signing(binding_signature),
+ # Revoking the primary revokes everything beneath it, so a live subkey binding isn't enough
+ revoked=primary_revoked or _subkey_is_revoked(binding),
)
if _issuer_is_primary(key, issuer_fingerprints, issuer_key_ids):
@@ -83,11 +95,12 @@ def signing_key_status(
identified=True,
expires=key_expires_at(key),
can_sign=_declares_signing(_effective_self_signature(key)),
+ revoked=primary_revoked,
)
# Naming no key we hold is not the same as naming the primary, and a signature we can't attribute
# is one we can't judge, so report it as such rather than borrowing the primary's answer
- return SigningKeyStatus(identified=False, expires=None, can_sign=False)
+ return SigningKeyStatus(identified=False, expires=None, can_sign=False, revoked=False)
def _binding_self_signatures(key: openpgp.PublicKey) -> list[openpgp.SignatureInfo]:
@@ -101,6 +114,9 @@ def _binding_self_signatures(key: openpgp.PublicKey) -> list[openpgp.SignatureIn
signature
for signature in binding.signatures
if _signature_is_self(signature, self_fingerprint, self_key_id)
+ # A cert-revocation is self-issued too, but carries no expiry or key flags, so admitting it
+ # here lets a revoked user id erase both for the whole key
+ and (signature.signature_type in _CERTIFICATION_SIGNATURE_TYPES)
)
return binding_sigs
@@ -180,6 +196,12 @@ def _latest_signature(signatures: list[openpgp.SignatureInfo]) -> openpgp.Signat
return max(signatures, key=lambda signature: signature.creation_time or 0)
+def _primary_is_revoked(key: openpgp.PublicKey) -> bool:
+ return any(
+ signature.signature_type == _KEY_REVOCATION_SIGNATURE_TYPE for signature in key.revocation_signature_infos()
+ )
+
+
def _signature_is_self(signature: openpgp.SignatureInfo, self_fingerprint: str, self_key_id: str) -> bool:
fingerprints = {fingerprint.lower() for fingerprint in signature.issuer_fingerprints}
key_ids = {key_id.lower() for key_id in signature.issuer_key_ids}
@@ -197,3 +219,9 @@ def _subkey_expires_at(
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)
+
+
+def _subkey_is_revoked(binding: openpgp.SubkeyBindingInfo) -> bool:
+ # The revocation can share a timestamp with the binding it kills, so this asks whether one exists
+ # at all rather than which of the two is more recent
+ return any(signature.signature_type == _SUBKEY_REVOCATION_SIGNATURE_TYPE for signature in binding.signatures)
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 15102152..76373426 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -91,6 +91,10 @@ async def add(
".",
]
await quart.flash(str(p), "warning")
+ elif key.status == datatypes.KeyStatus.REFRESHED:
+ await quart.flash(
+ f"OpenPGP key {fingerprint_upper} was updated to the version you uploaded.", "success"
+ )
else:
await quart.flash(f"OpenPGP key {fingerprint_upper} added successfully.", "success")
await _flash_openpgp_key_uid_warning(key.key_model, session.asf_uid)
diff --git a/atr/storage/datatypes.py b/atr/storage/datatypes.py
index c3dd9904..e799605d 100644
--- a/atr/storage/datatypes.py
+++ b/atr/storage/datatypes.py
@@ -46,6 +46,8 @@ class KeyStatus(enum.Flag):
PARSED = 0
INSERTED = enum.auto()
LINKED = enum.auto()
+ # The fingerprint was already stored, but its key block was replaced with a newer one
+ REFRESHED = enum.auto()
INSERTED_AND_LINKED = INSERTED | LINKED
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index c43c1db5..4fd46dac 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -477,11 +477,16 @@ class FoundationCommitter(GeneralPublic):
)
inserted = key_insert_result.one_or_none() is not None
undeleted = []
+ refreshed = False
if inserted:
await self.__signature_hints_consume([key])
else:
+ # The fingerprint only covers the primary packet, so an existing row can hold a stale
+ # block - a since-revoked, re-signed, or re-subkeyed export of the same key. Bring it up
+ # to date, which also carries the apache_uid the undelete below would otherwise set
+ refreshed = await self.__refresh_stored_key(key.key_model)
undeleted = await self.__undelete_keys([key.key_model.fingerprint])
- if undeleted:
+ if undeleted and not refreshed:
await self.__data.execute(
sqlmodel.update(sql.PublicSigningKey)
.where(via(sql.PublicSigningKey.fingerprint) == key.key_model.fingerprint)
@@ -499,6 +504,18 @@ class FoundationCommitter(GeneralPublic):
log.info(f"Undeleted key {key.key_model.fingerprint}")
return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model))
+ if refreshed:
+ self.__write_as.append_to_audit_log(
+ action="key_refresh",
+ asf_uid=self.__asf_uid,
+ fingerprint=key.key_model.fingerprint,
+ key_apache_uid=key.key_model.apache_uid,
+ )
+ # A changed block can flip an existing signature check, so let its committees catch up
+ await self.__sync_committees_for_keys([key.key_model.fingerprint])
+ log.info(f"Refreshed stored key {key.key_model.fingerprint}")
+ return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.REFRESHED, key_model=key.key_model))
+
if not inserted:
log.info(f"Key {key.key_model.fingerprint} already exists in database")
return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.PARSED, key_model=key.key_model))
@@ -587,6 +604,21 @@ and was published by the committee.\
full_keys_file_content = header_content + key_blocks_str
return full_keys_file_content
+ async def __refresh_stored_key(self, model: sql.PublicSigningKey) -> bool:
+ existing = await self.__data.public_signing_key(fingerprint=model.fingerprint, deleted=db.NOT_SET).get()
+ if (existing is None) or (existing.ascii_armored_key == model.ascii_armored_key):
+ return False
+ existing.ascii_armored_key = model.ascii_armored_key
+ existing.algorithm = model.algorithm
+ existing.length = model.length
+ existing.latest_self_signature = model.latest_self_signature
+ existing.expires = model.expires
+ existing.primary_declared_uid = model.primary_declared_uid
+ existing.secondary_declared_uids = model.secondary_declared_uids
+ existing.apache_uid = model.apache_uid
+ self.__data.add(existing)
+ return True
+
async def __signature_hints_consume(self, keys: list[datatypes.Key]) -> list[str]:
via = sql.validate_instrumented_attribute
member_map = {key.key_model.fingerprint: set(key.member_ids) for key in keys}
@@ -874,11 +906,40 @@ class CommitteeParticipant(FoundationCommitter):
committee = await self.committee()
key_values = [key.key_model.model_dump(exclude={"committees"}) for key in key_list]
+ incoming_blocks = {v["fingerprint"]: v["ascii_armored_key"] for v in key_values}
+ stored_blocks: dict[str, str] = {}
+ if incoming_blocks:
+ stored_result = await self.__data.execute(
+ sqlmodel.select(
+ via(sql.PublicSigningKey.fingerprint),
+ via(sql.PublicSigningKey.ascii_armored_key),
+ ).where(via(sql.PublicSigningKey.fingerprint).in_(sorted(incoming_blocks)))
+ )
+ stored_blocks = {fingerprint: block for fingerprint, block in stored_result.all()}
+ # A key we already hold whose incoming block differs has been re-imported with a newer state,
+ # which can flip an existing signature check even when nothing about the association changed
+ refreshed_fingerprints = {
+ fingerprint
+ for fingerprint, block in incoming_blocks.items()
+ if (fingerprint in stored_blocks) and (stored_blocks[fingerprint] != block)
+ }
if key_values:
stmt = sqlite.insert(sql.PublicSigningKey).values(key_values)
+ # The fingerprint only covers the primary packet, so a re-import can carry a newer block
+ # for a key we already hold - a revocation, an extended expiry, a fresh subkey. Refresh
+ # the whole derived row, not just the apache_uid
stmt = stmt.on_conflict_do_update(
index_elements=["fingerprint"],
- set_={"apache_uid": stmt.excluded.apache_uid},
+ set_={
+ "apache_uid": stmt.excluded.apache_uid,
+ "ascii_armored_key": stmt.excluded.ascii_armored_key,
+ "algorithm": stmt.excluded.algorithm,
+ "length": stmt.excluded.length,
+ "latest_self_signature": stmt.excluded.latest_self_signature,
+ "expires": stmt.excluded.expires,
+ "primary_declared_uid": stmt.excluded.primary_declared_uid,
+ "secondary_declared_uids": stmt.excluded.secondary_declared_uids,
+ },
)
key_insert_result = await self.__data.execute(
stmt.returning(via(sql.PublicSigningKey.fingerprint)),
@@ -947,7 +1008,7 @@ class CommitteeParticipant(FoundationCommitter):
fingerprints=[f for f in undeleted],
)
await self.__sync_committees_for_keys(undeleted)
- if link_inserts:
+ if link_inserts or refreshed_fingerprints:
await self._recheck_committee_drafts(self.__committee_key)
return outcomes
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index 99b410d8..814bd686 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -480,7 +480,10 @@ async def _resolve_committee_signing_keys(release: sql.Release, rel_path: str |
committee_key = release.committee.key
async with db.session() as data:
statement = (
- sqlmodel.select(via(sql.KeyLink.key_fingerprint))
+ sqlmodel.select(
+ via(sql.KeyLink.key_fingerprint),
+ via(sql.PublicSigningKey.ascii_armored_key),
+ )
.join(sql.PublicSigningKey)
.where(
via(sql.KeyLink.committee_key) == committee_key,
@@ -488,8 +491,17 @@ async def _resolve_committee_signing_keys(release: sql.Release, rel_path: str |
)
)
result = await data.execute(statement)
- fingerprints = result.scalars().all()
- return sorted(fp for fp in fingerprints if fp)
+ rows = result.all()
+
+ # A revocation or expiry change leaves the fingerprint alone but rewrites the key block, so the
+ # block content is what the cache has to track to know the check needs re-running.
+ entries = []
+ for fingerprint, block in rows:
+ if not fingerprint:
+ continue
+ text = block if isinstance(block, str) else block.decode("utf-8", errors="replace")
+ entries.append(f"{fingerprint}:{text}")
+ return sorted(entries)
async def _resolve_cross_format_sibling_swhids(release: sql.Release, rel_path: str | None = None) -> list[str]:
diff --git a/atr/tasks/checks/signature.py b/atr/tasks/checks/signature.py
index f47902fb..2c9a22be 100644
--- a/atr/tasks/checks/signature.py
+++ b/atr/tasks/checks/signature.py
@@ -34,7 +34,7 @@ import atr.util as util
# Release policy fields which this check relies on - used for result caching
INPUT_POLICY_KEYS: Final[list[str]] = []
INPUT_EXTRA_ARGS: Final[list[str]] = ["committee_key", "committee_signing_keys", "unsuffixed_file_hash"]
-CHECK_VERSION: Final[str] = "5"
+CHECK_VERSION: Final[str] = "6"
async def check(args: checks.FunctionArguments) -> results.Results | None:
@@ -342,6 +342,13 @@ def _key_refusal_result(
num_committee_keys=num_committee_keys,
key_has_apache_uid=key_has_apache_uid,
)
+ if status.revoked:
+ return _revoked_key_result(
+ matched_key=matched_key,
+ signature_info=signature_info,
+ num_committee_keys=num_committee_keys,
+ key_has_apache_uid=key_has_apache_uid,
+ )
if (status.expires is not None) and (status.expires <= datetime.datetime.now(datetime.UTC)):
return _expired_key_result(
matched_key=matched_key,
@@ -398,6 +405,32 @@ def _parse_public_keys(ascii_armored_keys: list[str]) -> list[openpgp.PublicKey]
return public_keys
+def _revoked_key_result(
+ *,
+ matched_key: openpgp.PublicKey,
+ signature_info: openpgp.SignatureInfo,
+ num_committee_keys: int,
+ key_has_apache_uid: bool,
+) -> dict[str, Any]:
+ debug_info = _debug_info(
+ key=matched_key,
+ signature_info=signature_info,
+ status="Invalid: Signing key revoked",
+ valid=False,
+ num_committee_keys=num_committee_keys,
+ key_has_apache_uid=key_has_apache_uid,
+ )
+ return {
+ "verified": False,
+ "error": "Signature was made by a revoked key",
+ "error_kind": "revoked_key",
+ "hint": "Sign the artifact with a key which has not been revoked, then",
+ "hint_link_text": "check your keys",
+ "hint_link": "/keys",
+ "debug_info": debug_info,
+ }
+
+
def _signer_username(key: openpgp.PublicKey, signature_info: openpgp.SignatureInfo) -> str | None:
if signature_info.signer_user_id:
return signature_info.signer_user_id
diff --git a/tests/unit/pgp_fixtures.py b/tests/unit/pgp_fixtures.py
index 7756e640..a2482a45 100644
--- a/tests/unit/pgp_fixtures.py
+++ b/tests/unit/pgp_fixtures.py
@@ -69,3 +69,108 @@ def write_expired_subkey_fixture(tmp_path: pathlib.Path) -> tuple[str, str]:
artifact_path.write_bytes(EXPIRED_SUBKEY_ARTIFACT_BYTES)
signature_path.write_text(EXPIRED_SUBKEY_DETACHED_SIGNATURE_ASC, encoding="utf-8")
return str(signature_path), str(artifact_path)
+
+
+# A certificate whose signing subkey was revoked after it had signed. The signature below was made
+# while the subkey was still good, so only a check which reads the revocation will reject it
+REVOKED_SUBKEY_PRIMARY_FINGERPRINT: Final[str] = "627e587f1f3f8cd99cc8c6781e02faf30b9b2d9d"
+REVOKED_SUBKEY_SIGNING_FINGERPRINT: Final[str] = "5b024ae4e7690e808c1c3a18c13114edca77d92f"
+
+REVOKED_SUBKEY_ARTIFACT_BYTES: Final[bytes] = b"atr revocation fixture\n"
+
+REVOKED_SUBKEY_PUBLIC_KEY_ASC: Final[str] = """-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEalpBFhYJKwYBBAHaRw8BAQdAjDlZ6Lr4uf3ylu8ozNd5ZeyG9dPPeG/vHH6l
+QVRSW+q0R0FUUiBSZXZva2VkIFN1YmtleSAoRm9yIHRlc3QgdXNlIG9ubHkpIDxy
+ZXZva2VkLXN1YmtleUBleGFtcGxlLmludmFsaWQ+iJMEExYKADsWIQRiflh/Hz+M
+2ZzIxngeAvrzC5stnQUCalpBFgIbAQULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIX
+gAAKCRAeAvrzC5stnfdAAQCDqDG6MbixdM9Vxsouxg3IeM3xSPH00uv8zs7ds9je
+NgD+OuBJ81xTQF2Nud+aZO9QPbr8JVRebjDi517pMiE+kA64MwRqWkEWFgkrBgEE
+AdpHDwEBB0DYrVxiE6+rIZt5WhehU+Y1ZgjSUmS4LUjQBeBxcs78u4h4BCgWCgAg
+FiEEYn5Yfx8/jNmcyMZ4HgL68wubLZ0FAmpaQRYCHQIACgkQHgL68wubLZ21oQEA
+2POrR1Ff0xJik+0M17V8nu8zCagAauhiM9S7nKD/5bgBAI/pD0d08UMLGqDFzIO7
+GlxonRgJsv+LcRh0Mw5yyVUEiO8EGBYKACAWIQRiflh/Hz+M2ZzIxngeAvrzC5st
+nQUCalpBFgIbAgCBCRAeAvrzC5stnXYgBBkWCgAdFiEEWwJK5OdpDoCMHDoYwTEU
+7cp32S8FAmpaQRYACgkQwTEU7cp32S8PsQD+P1bArcgoslNBVjKS1WvxmO7LQv+1
+/xymuXAvvVyIARsA/2bv6hKQMYhqrGERZ7ptUh9z6x15tgLGiS3UMr5FSW0LepYB
+AJOv44nvZhSS7FEFKEVUDA8wFXMkN8b0vONjttnOSVqQAP9aONCFDVFZHDwxhXe2
+Ai4vJkztXwDtM9aamALxT+OrDw==
+=2c/y
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+
+REVOKED_SUBKEY_DETACHED_SIGNATURE_ASC: Final[str] = """-----BEGIN PGP SIGNATURE-----
+
+iHUEABYKAB0WIQRbAkrk52kOgIwcOhjBMRTtynfZLwUCalpBFgAKCRDBMRTtynfZ
+L96xAP9O/POM9QlwStFscv/Ylj2AcGmnRIipcdYtG63grTTgWwD8C0d+wPULT/oY
+9gkQVkwcs5X4x8x+qKKvLLQHAgX3jgg=
+=wOC7
+-----END PGP SIGNATURE-----
+"""
+
+
+# A certificate whose primary key is revoked. Revoking the primary revokes everything beneath it, so
+# its still-bound signing subkey is unusable too
+REVOKED_PRIMARY_FINGERPRINT: Final[str] = "894e4b57dca9772b6b692b6a1d725675acbdb724"
+REVOKED_PRIMARY_SIGNING_FINGERPRINT: Final[str] = "d52d750d3e80bede5553b2edaf0a1d93538cd91e"
+
+REVOKED_PRIMARY_PUBLIC_KEY_ASC: Final[str] = """-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEalpBFhYJKwYBBAHaRw8BAQdAKUdQxIFXyT1Nn153YCA/poF2sSjombcngmqF
+0z8FAxyIeAQgFgoAIBYhBIlOS1fcqXcra2krah1yVnWsvbckBQJqWkEWAh0AAAoJ
+EB1yVnWsvbck1/sBAJPxFkfg+Cz8+RLXieMId3W7z1ySbAY0ZXdW0+zIXTs6AP0V
+lbtW9cFIu8fpMPQxw7ghJAiT/NcLewQTSdc1QfZ8DbRJQVRSIFJldm9rZWQgUHJp
+bWFyeSAoRm9yIHRlc3QgdXNlIG9ubHkpIDxyZXZva2VkLXByaW1hcnlAZXhhbXBs
+ZS5pbnZhbGlkPoiTBBMWCgA7FiEEiU5LV9ypdytraStqHXJWday9tyQFAmpaQRYC
+GwEFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQHXJWday9tyRzUAEA8dxQ
+eXeLraUvbKmTwArK4EBez/kfb/iAw3SjhcNzlQgBAK7rYpI/2O7lCEJ/Ibhom9cI
+CcY0CEWd4CB148F+bEQMuDMEalpBFhYJKwYBBAHaRw8BAQdAWTKqGwULwFLUhP2f
+plzat/55UCABJStYrUeeCwbbXYGI7wQYFgoAIBYhBIlOS1fcqXcra2krah1yVnWs
+vbckBQJqWkEWAhsCAIEJEB1yVnWsvbckdiAEGRYKAB0WIQTVLXUNPoC+3lVTsu2v
+Ch2TU4zZHgUCalpBFgAKCRCvCh2TU4zZHovHAP46C9cmUu4ep4HuMhsdALUH7V2F
+i0zZxlh06TsoCyjqegD/Wc+lxUjExmc6Nwth8iib5BqB8NUxYNO42AHQRW1y6Aq6
+9wD+NEFQrTDnM7165KMXcprJwmixQAS8sVKsTuuQRo642hABAOoifUNE9r4/6Bzm
+ixdipMf9A5HgGyWO0mNWIGk+ZRIH
+=Sivq
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+
+
+# A certificate with a revoked secondary user id. The key itself stays entirely valid: reading the
+# uid revocation as a key-level self-signature would erase its expiry and its declared capabilities
+REVOKED_UID_FINGERPRINT: Final[str] = "346c2e8cd37b4f7c0bde03d9fdefe91a648ddd76"
+REVOKED_UID_SIGNING_FINGERPRINT: Final[str] = "5aec39f36f29b0d02ac67eb6dd6e33ceb5d275a6"
+REVOKED_UID_PRIMARY_EXPIRES_YEAR: Final[int] = 2030
+
+REVOKED_UID_PUBLIC_KEY_ASC: Final[str] = """-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEalpBFhYJKwYBBAHaRw8BAQdAo/CQbE40pWaoJHDJ+zqNttN6+Op4Nb/7cFFw
+xev24XO0QUFUUiBSZXZva2VkIFVpZCAoRm9yIHRlc3QgdXNlIG9ubHkpIDxyZXZv
+a2VkLXVpZEBleGFtcGxlLmludmFsaWQ+iJkEExYKAEEWIQQ0bC6M03tPfAveA9n9
+7+kaZI3ddgUCalpBFgIbAQUJCGIiKgULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIX
+gAAKCRD97+kaZI3ddoKJAP0Qj4S7sl729rTc0UxbGeQur/GyuY/38H9LOCZUqsQ3
+UQEA+fneJoJtrT/TCCe9F+3xKDGLRrFzXbM5j6jwRTaILgK0NkFUUiBSZXZva2Vk
+IFVpZCBTZWNvbmQgPHJldm9rZWQtdWlkLTJAZXhhbXBsZS5pbnZhbGlkPoh4BDAW
+CgAgFiEENGwujNN7T3wL3gPZ/e/pGmSN3XYFAmpaQRcCHSAACgkQ/e/pGmSN3XYT
+EQD/UjqNftLvxzE8ah+nHWZZ3SRTSCcdZC6B/Hak4dd92gkA/3HPMo3BNSky+mml
+KSFbjOCembdIL9cWhsk/60T70ZADiJkEExYKAEEWIQQ0bC6M03tPfAveA9n97+ka
+ZI3ddgUCalpBFgIbAQUJCGIiKgULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAK
+CRD97+kaZI3ddv1AAP9nXCJtCPupIhiFj8MJmf5zs1iB8mvB8EExA62MEHFLMgD/
+Rfj3KhAGm8D6+e+T11dtP4RSeAev6OA8smDj1WBldQO4MwRqWkEWFgkrBgEEAdpH
+DwEBB0DfkeUI+ub7ARbR7L1mpf7IWypAnCO5dOzjcnJ6dwlSQ4jvBBgWCgAgFiEE
+NGwujNN7T3wL3gPZ/e/pGmSN3XYFAmpaQRYCGwIAgQkQ/e/pGmSN3XZ2IAQZFgoA
+HRYhBFrsOfNvKbDQKsZ+tt1uM8610nWmBQJqWkEWAAoJEN1uM8610nWmYHwA/1ng
+VdOTntQA+bbo+vHhqEGdpDvw/a1r6M2lxtBT87+2AQC1UhAkMxN0vdAZczqlBMFC
+i05PRe7RtkmV0875EUYpAz1fAP4yeSCHoNNOtOZKj3+uZuUeoDFyC6afH/LAmU+w
+rkJ3LQEA8l4Luk7tZdHZyq+w5q+hbbkRz4X5RJh3091rYGqBtAQ=
+=P9R1
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+
+
+def write_revoked_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"
+ artifact_path.write_bytes(REVOKED_SUBKEY_ARTIFACT_BYTES)
+ signature_path.write_text(REVOKED_SUBKEY_DETACHED_SIGNATURE_ASC, encoding="utf-8")
+ return str(signature_path), str(artifact_path)
diff --git a/tests/unit/test_checks_signature.py b/tests/unit/test_checks_signature.py
index f48b8c2b..c1d53161 100644
--- a/tests/unit/test_checks_signature.py
+++ b/tests/unit/test_checks_signature.py
@@ -288,6 +288,22 @@ def test_check_core_logic_rejects_signature_from_an_expired_subkey(tmp_path: pat
assert result["error_kind"] == "key_expired"
+def test_check_core_logic_rejects_signature_from_a_revoked_subkey(tmp_path: pathlib.Path) -> None:
+ # The signature was made while the subkey was still good, so it verifies cryptographically and
+ # only a revocation check can reject it
+ signature_path, artifact_path = pgp_fixtures.write_revoked_subkey_fixture(tmp_path)
+
+ result = signature_check._check_core_logic_verify_signature(
+ signature_path=signature_path,
+ artifact_path=artifact_path,
+ ascii_armored_keys=[pgp_fixtures.REVOKED_SUBKEY_PUBLIC_KEY_ASC],
+ apache_uid_map={pgp_fixtures.REVOKED_SUBKEY_PRIMARY_FINGERPRINT: True},
+ )
+
+ assert result["verified"] is False
+ assert result["error_kind"] == "revoked_key"
+
+
def test_key_matches_signature_accepts_subkey_issuer_metadata() -> None:
public_key, _ = openpgp.PublicKey.from_armor(_EMBEDDED_PUBLIC_KEY_ASC)
signature, _ = openpgp.DetachedSignature.from_armor(_EMBEDDED_DETACHED_SIGNATURE_ASC)
diff --git a/tests/unit/test_keys_delete.py b/tests/unit/test_keys_delete.py
index 55edcad6..cb19cfc0 100644
--- a/tests/unit/test_keys_delete.py
+++ b/tests/unit/test_keys_delete.py
@@ -59,9 +59,9 @@ async def test_committee_signing_keys_exclude_deleted(sqlite_sessionmaker, monke
monkeypatch.setattr(checks.db, "session", lambda: sqlite_sessionmaker())
release = SimpleNamespace(committee=SimpleNamespace(key="tooling"))
- fingerprints = await checks._resolve_committee_signing_keys(release)
+ signing_keys = await checks._resolve_committee_signing_keys(release)
- assert fingerprints == [BETA_FINGERPRINT]
+ assert signing_keys == [f"{BETA_FINGERPRINT}:armored beta"]
async def test_delete_committee_keys_deletes_orphans(sqlite_sessionmaker) -> None:
@@ -187,6 +187,40 @@ async def test_reupload_undeletes_and_restores_associations(sqlite_sessionmaker)
assert sorted(k.fingerprint for k in committee.public_signing_keys) == [ALPHA_FINGERPRINT, BETA_FINGERPRINT]
+async def test_reupload_with_a_changed_block_refreshes_it(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ await _seed_keys(data)
+ writer = _committer_writer(data)
+ key = datatypes.Key(
+ status=datatypes.KeyStatus.PARSED,
+ key_model=_key(ALPHA_FINGERPRINT, "alice", "armored alpha revoked"),
+ )
+
+ oc = await writer._FoundationCommitter__database_add_model(key)
+
+ assert oc.result_or_raise().status == datatypes.KeyStatus.REFRESHED
+ row = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
+ assert row is not None
+ assert row.ascii_armored_key == "armored alpha revoked"
+ # A changed block can flip a signature check, so the key's committees must be rechecked
+ writer._recheck_committee_drafts.assert_awaited()
+
+
+async def test_reupload_with_an_unchanged_block_is_a_noop(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ await _seed_keys(data)
+ writer = _committer_writer(data)
+ key = datatypes.Key(
+ status=datatypes.KeyStatus.PARSED,
+ key_model=_key(ALPHA_FINGERPRINT, "alice", "armored alpha"),
+ )
+
+ oc = await writer._FoundationCommitter__database_add_model(key)
+
+ assert oc.result_or_raise().status == datatypes.KeyStatus.PARSED
+ writer._recheck_committee_drafts.assert_not_awaited()
+
+
async def test_signature_hints_consume_flags_and_deletes(sqlite_sessionmaker) -> None:
async with sqlite_sessionmaker() as data:
await _seed_keys(data)
diff --git a/tests/unit/test_pgp.py b/tests/unit/test_pgp.py
index adfae9ee..bc5cb9dd 100644
--- a/tests/unit/test_pgp.py
+++ b/tests/unit/test_pgp.py
@@ -106,3 +106,57 @@ def test_declares_signing_permits_a_key_declaring_no_capabilities() -> None:
def test_declares_signing_refuses_a_key_whose_self_signature_cannot_be_read() -> None:
assert pgp._declares_signing(None) is False
+
+
+def test_signing_key_status_flags_a_revoked_signing_subkey() -> None:
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_SUBKEY_PUBLIC_KEY_ASC)
+
+ status = pgp.signing_key_status(key, {pgp_fixtures.REVOKED_SUBKEY_SIGNING_FINGERPRINT}, set())
+
+ assert status.revoked is True
+
+
+def test_signing_key_status_flags_a_subkey_beneath_a_revoked_primary() -> None:
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_PRIMARY_PUBLIC_KEY_ASC)
+
+ # The subkey binding is intact, so only the primary's revocation cascading down can reject it
+ status = pgp.signing_key_status(key, {pgp_fixtures.REVOKED_PRIMARY_SIGNING_FINGERPRINT}, set())
+
+ assert status.revoked is True
+
+
+def test_signing_key_status_flags_a_revoked_primary() -> None:
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_PRIMARY_PUBLIC_KEY_ASC)
+
+ status = pgp.signing_key_status(key, {pgp_fixtures.REVOKED_PRIMARY_FINGERPRINT}, set())
+
+ assert status.revoked is True
+
+
+def test_signing_key_status_does_not_treat_a_revoked_user_id_as_a_revoked_key() -> None:
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_UID_PUBLIC_KEY_ASC)
+
+ status = pgp.signing_key_status(key, {pgp_fixtures.REVOKED_UID_FINGERPRINT}, set())
+
+ assert status.revoked is False
+
+
+def test_key_expires_at_ignores_a_revoked_user_id_self_signature() -> None:
+ # A revocation is self-issued but declares no expiry, so reading it as the effective self-signature
+ # would erase the expiry the certification actually carries
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_UID_PUBLIC_KEY_ASC)
+
+ expires = pgp.key_expires_at(key)
+
+ assert expires is not None
+ assert expires.year == pgp_fixtures.REVOKED_UID_PRIMARY_EXPIRES_YEAR
+
+
+def test_signing_key_status_reads_capabilities_past_a_revoked_user_id() -> None:
+ # The same revocation carries no key flags, so admitting it would flip a certify-only primary to
+ # can_sign purely because a secondary address was revoked
+ key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_UID_PUBLIC_KEY_ASC)
+
+ status = pgp.signing_key_status(key, {pgp_fixtures.REVOKED_UID_FINGERPRINT}, set())
+
+ assert status.can_sign is False
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org