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/21 16:30:50 UTC

(tooling-trusted-releases) branch signing_key_model updated (72d7493a -> dceb8c6b)

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a change to branch signing_key_model
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


 discard 72d7493a #1419 - Datamodel update to handle subkeys. Prevent overwriting older keys.
 discard 2c14eefc #1420 - auto-archive on CAP approval and handle failure to do so
 discard 831db712 #1427 - revert token change
 discard 93bab29e #1417 - Reject on revoked
     add b42df9fd Update dependencies
     add c5bb29f8 Allow SBOM signatures
     add 956a15a4 Parse attestable files in JSON mode
     add 5545dc1a Parse attestable checks files in JSON mode
     add a95c7ab8 Remove links to ATR's own downloads area
     add 5def9227 #1417 - Reject on revoked
     add 8c36af5a #1427 - revert token change
     add b424c793 #1420 - auto-archive on CAP approval and handle failure to do so
     new dceb8c6b #1419 - Datamodel update to handle subkeys. Prevent overwriting older keys.

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   (72d7493a)
            \
             N -- N -- N   refs/heads/signing_key_model (dceb8c6b)

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:
 .pre-commit-config.yaml                  |  2 +-
 atr/attestable.py                        | 21 ++++++-----
 atr/construct.py                         |  9 +----
 atr/get/announce.py                      |  3 +-
 atr/paths.py                             | 14 ++-----
 atr/shared/catalog.py                    | 30 +++++++--------
 atr/storage/writers/announce.py          |  2 +-
 atr/tasks/checks/paths.py                |  2 +
 atr/util.py                              | 54 ++++++--------------------
 pip-audit.requirements                   |  6 +--
 tests/e2e/announce/test_get.py           |  7 ++--
 tests/unit/test_attestable.py            | 13 ++++++-
 tests/unit/test_catalog.py               | 65 ++++++++++++++++++++++----------
 tests/unit/test_construct_announce.py    | 35 ++++-------------
 tests/unit/test_paths.py                 | 28 +++++++-------
 tests/unit/test_source_artifact_check.py | 25 ++++++++++++
 tests/unit/test_svn_publish_helpers.py   | 64 +++++++++++++++++--------------
 uv.lock                                  | 56 +++++++++++++--------------
 18 files changed, 222 insertions(+), 214 deletions(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: #1419 - Datamodel update to handle subkeys. Prevent overwriting older keys.

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch signing_key_model
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit dceb8c6b107a95b0c7bd2d3a2906ca4c68d865c8
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Jul 21 16:03:11 2026 +0100

    #1419 - Datamodel update to handle subkeys. Prevent overwriting older keys.
---
 atr/admin/__init__.py                           |   8 +-
 atr/api/__init__.py                             |  20 +--
 atr/db/__init__.py                              |  48 ++---
 atr/db/interaction.py                           |  10 +-
 atr/get/committees.py                           |  18 +-
 atr/get/keys.py                                 | 127 ++++++++++----
 atr/get/projects.py                             |   2 +-
 atr/models/api.py                               |  10 +-
 atr/models/sql.py                               |  89 +++++++---
 atr/pgp.py                                      |  97 ++++++++++-
 atr/post/keys.py                                |   4 +-
 atr/storage/datatypes.py                        |   4 +-
 atr/storage/writers/announce.py                 |  15 +-
 atr/storage/writers/keys.py                     | 223 ++++++++++++++++--------
 atr/tasks/checks/__init__.py                    |   8 +-
 atr/tasks/checks/signature.py                   |  27 +--
 atr/tasks/maintenance.py                        |   4 +-
 migrations/versions/0110_2026.07.20_7c41ab93.py | 171 ++++++++++++++++++
 scripts/hints_import.py                         |   6 +-
 tests/unit/test_keys_delete.py                  |  48 +++--
 tests/unit/test_keys_post.py                    |   4 +-
 tests/unit/test_keys_writer.py                  |  57 +++---
 tests/unit/test_pgp.py                          |  68 ++++++++
 tests/unit/test_signature_provenance.py         |  10 +-
 24 files changed, 795 insertions(+), 283 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 09a64224..2970c675 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -1019,8 +1019,8 @@ async def delete_committee_keys_get(
     Display the form to delete committee keys.
     """
     async with db.session() as data:
-        all_committees = await data.committee(_public_signing_keys=True).order_by(sql.Committee.key).all()
-        committees_with_keys = [c for c in all_committees if c.public_signing_keys]
+        all_committees = await data.committee(_signing_certificates=True).order_by(sql.Committee.key).all()
+        committees_with_keys = [c for c in all_committees if c.signing_certificates]
 
     committee_choices = [(c.key, c.display_name) for c in committees_with_keys]
 
@@ -2146,7 +2146,7 @@ async def _check_keys(fix: bool = False) -> str:
     email_uid_lookup = await cache.email_uid_view_or_live()
     bad_keys = []
     async with db.session() as data:
-        keys = await data.public_signing_key().all()
+        keys = await data.signing_certificate().all()
         for key in keys:
             uids = []
             if key.primary_declared_uid:
@@ -2175,7 +2175,7 @@ async def _data_browse(_session: web.Committer, model: str = "Committee") -> str
             "CheckResultIgnore": data.check_result_ignore,
             "Committee": data.committee,
             "Project": data.project,
-            "PublicSigningKey": lambda: data.public_signing_key(deleted=db.NOT_SET),
+            "SigningCertificate": lambda: data.signing_certificate(deleted=db.NOT_SET),
             "Release": data.release,
             "ReleasePolicy": data.release_policy,
             "Revision": data.revision,
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index b7409621..47b2fd7f 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -342,12 +342,12 @@ async def committee_keys(
     "simple-example".
     """
     async with db.session() as data:
-        committee = await data.committee(key=str(name), _public_signing_keys=True).demand(
+        committee = await data.committee(key=str(name), _signing_certificates=True).demand(
             exceptions.NotFound(f"Committee '{name!s}' was not found")
         )
     return models.api.CommitteeKeysResults(
         endpoint="/committee/keys",
-        keys=committee.public_signing_keys,
+        keys=committee.signing_certificates,
     ).model_dump(mode="json"), 200
 
 
@@ -793,7 +793,7 @@ async def key_get(
     All public OpenPGP keys stored within the database are accessible.
     """
     async with db.session() as data:
-        key = await data.public_signing_key(fingerprint=str(fingerprint).lower()).demand(
+        key = await data.signing_certificate(fingerprint=str(fingerprint).lower()).demand(
             exceptions.NotFound(f"Key '{fingerprint!s}' not found")
         )
     return models.api.KeyGetResults(
@@ -887,7 +887,7 @@ async def keys_user(
     List public OpenPGP keys by the ASF UID of a user.
     """
     async with db.session() as data:
-        keys = await data.public_signing_key(apache_uid=str(asf_uid)).all()
+        keys = await data.signing_certificate(apache_uid=str(asf_uid)).all()
     return models.api.KeysUserResults(
         endpoint="/keys/user",
         keys=keys,
@@ -1844,7 +1844,7 @@ async def users_list(
     # PersonalAccessToken.asfuid
     # PersonalAccessToken.created_by
     # SSHKey.asf_uid
-    # PublicSigningKey.apache_uid
+    # SigningCertificate.apache_uid
     # Revision.asfuid
     async with db.session() as data:
         # TODO: Combine these queries
@@ -1859,8 +1859,8 @@ async def users_list(
         ssh_uids = set(result.scalars().all())
 
         result = await data.execute(
-            sqlalchemy.select(via(sql.PublicSigningKey.apache_uid))
-            .where(via(sql.PublicSigningKey.deleted).is_(None))
+            sqlalchemy.select(via(sql.SigningCertificate.apache_uid))
+            .where(via(sql.SigningCertificate.deleted).is_(None))
             .distinct()
         )
         public_signing_uids = set(result.scalars().all())
@@ -2323,13 +2323,13 @@ async def _resolve_signing_key_from_signature(
     db_data: db.Session,
     issuer_fingerprints: set[str],
     issuer_key_ids: set[str],
-) -> tuple[sql.PublicSigningKey, str]:
+) -> tuple[sql.SigningCertificate, str]:
     if not issuer_key_ids:
         for fingerprint in issuer_fingerprints:
-            stored = await db_data.public_signing_key(fingerprint=fingerprint, _committees=True).get()
+            stored = await db_data.signing_certificate(fingerprint=fingerprint, _committees=True).get()
             if stored is not None:
                 return stored, fingerprint
-    candidates = await db_data.public_signing_key(_committees=True).all()
+    candidates = await db_data.signing_certificate(_committees=True).all()
     for stored in candidates:
         armored = stored.ascii_armored_key
         if isinstance(armored, bytes):
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index acb75a9b..09a0893e 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -349,7 +349,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         has_participant: Opt[str] = NOT_SET,
         _child_committees: bool = False,
         _projects: bool = False,
-        _public_signing_keys: bool = False,
+        _signing_certificates: bool = False,
     ) -> Query[sql.Committee]:
         via = sql.validate_instrumented_attribute
         query = sqlmodel.select(sql.Committee)
@@ -387,8 +387,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.options(select_in_load(sql.Committee.child_committees))
         if _projects:
             query = query.options(select_in_load(sql.Committee.projects))
-        if _public_signing_keys:
-            query = query.options(select_in_load(sql.Committee.public_signing_keys))
+        if _signing_certificates:
+            query = query.options(select_in_load(sql.Committee.signing_certificates))
 
         return Query(self, query)
 
@@ -508,7 +508,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         _distribution_channels: bool = False,
         _super_project: bool = False,
         _release_policy: bool = False,
-        _committee_public_signing_keys: bool = False,
+        _committee_signing_certificates: bool = False,
     ) -> Query[sql.Project]:
         query = sqlmodel.select(sql.Project)
 
@@ -526,10 +526,10 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(sql.Project.status == status)
 
         # Avoid multiple loaders for Project.committee on the same path
-        if _committee_public_signing_keys:
+        if _committee_signing_certificates:
             query = query.options(
                 joined_load(sql.Project.committee).selectinload(
-                    sql.validate_instrumented_attribute(sql.Committee.public_signing_keys)
+                    sql.validate_instrumented_attribute(sql.Committee.signing_certificates)
                 )
             )
         elif _committee:
@@ -568,13 +568,9 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
 
         return Query(self, query)
 
-    def public_signing_key(
+    def signing_certificate(
         self,
         fingerprint: Opt[str] = NOT_SET,
-        algorithm: Opt[str] = NOT_SET,
-        length: Opt[int] = NOT_SET,
-        created: Opt[datetime.datetime] = NOT_SET,
-        expires: Opt[datetime.datetime | None] = NOT_SET,
         primary_declared_uid: Opt[str | None] = NOT_SET,
         secondary_declared_uids: Opt[list[str]] = NOT_SET,
         apache_uid: Opt[str | None] = NOT_SET,
@@ -582,38 +578,30 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         deleted: Opt[bool] = False,
         historic_use: Opt[bool] = NOT_SET,
         _committees: bool = False,
-    ) -> Query[sql.PublicSigningKey]:
+    ) -> Query[sql.SigningCertificate]:
         via = sql.validate_instrumented_attribute
-        query = sqlmodel.select(sql.PublicSigningKey)
+        query = sqlmodel.select(sql.SigningCertificate)
 
         if is_defined(fingerprint):
-            query = query.where(via(sql.PublicSigningKey.fingerprint) == fingerprint)
-        if is_defined(algorithm):
-            query = query.where(via(sql.PublicSigningKey.algorithm) == algorithm)
-        if is_defined(length):
-            query = query.where(via(sql.PublicSigningKey.length) == length)
-        if is_defined(created):
-            query = query.where(via(sql.PublicSigningKey.created) == created)
-        if is_defined(expires):
-            query = query.where(via(sql.PublicSigningKey.expires) == expires)
+            query = query.where(via(sql.SigningCertificate.fingerprint) == fingerprint)
         if is_defined(primary_declared_uid):
-            query = query.where(via(sql.PublicSigningKey.primary_declared_uid) == primary_declared_uid)
+            query = query.where(via(sql.SigningCertificate.primary_declared_uid) == primary_declared_uid)
         if is_defined(secondary_declared_uids):
-            query = query.where(via(sql.PublicSigningKey.secondary_declared_uids) == secondary_declared_uids)
+            query = query.where(via(sql.SigningCertificate.secondary_declared_uids) == secondary_declared_uids)
         if is_defined(apache_uid):
-            query = query.where(via(sql.PublicSigningKey.apache_uid) == apache_uid)
+            query = query.where(via(sql.SigningCertificate.apache_uid) == apache_uid)
         if is_defined(ascii_armored_key):
-            query = query.where(via(sql.PublicSigningKey.ascii_armored_key) == ascii_armored_key)
+            query = query.where(via(sql.SigningCertificate.ascii_armored_key) == ascii_armored_key)
         if is_defined(deleted):
             if deleted:
-                query = query.where(via(sql.PublicSigningKey.deleted).is_not(None))
+                query = query.where(via(sql.SigningCertificate.deleted).is_not(None))
             else:
-                query = query.where(via(sql.PublicSigningKey.deleted).is_(None))
+                query = query.where(via(sql.SigningCertificate.deleted).is_(None))
         if is_defined(historic_use):
-            query = query.where(via(sql.PublicSigningKey.historic_use) == historic_use)
+            query = query.where(via(sql.SigningCertificate.historic_use) == historic_use)
 
         if _committees:
-            query = query.options(select_in_load(sql.PublicSigningKey.committees))
+            query = query.options(select_in_load(sql.SigningCertificate.committees))
 
         return Query(self, query)
 
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 38f1fe9e..46911de4 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -155,18 +155,18 @@ async def automated_release_signing_committees(caller_data: db.Session | None =
     async with db.ensure_session(caller_data) as data:
         via = sql.validate_instrumented_attribute
         query = (
-            sqlmodel.select(sql.PublicSigningKey)
-            .options(orm.selectinload(via(sql.PublicSigningKey.committees)))
+            sqlmodel.select(sql.SigningCertificate)
+            .options(orm.selectinload(via(sql.SigningCertificate.committees)))
             .where(
                 sqlalchemy.and_(
                     sqlalchemy.or_(
                         *(
-                            via(sql.PublicSigningKey.primary_declared_uid).like(f"%{label}%")
+                            via(sql.SigningCertificate.primary_declared_uid).like(f"%{label}%")
                             for label in util.AUTOMATED_RELEASE_SIGNING_LABELS
                         )
                     ),
-                    via(sql.PublicSigningKey.primary_declared_uid).like("%private@%.apache.org%"),
-                    via(sql.PublicSigningKey.deleted).is_(None),
+                    via(sql.SigningCertificate.primary_declared_uid).like("%private@%.apache.org%"),
+                    via(sql.SigningCertificate.deleted).is_(None),
                 )
             )
         )
diff --git a/atr/get/committees.py b/atr/get/committees.py
index 7cd18ab3..832d45da 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -70,7 +70,7 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
         committee = await data.committee(
             key=str(name),
             _projects=True,
-            _public_signing_keys=True,
+            _signing_certificates=True,
         ).demand(base.ASFQuartException(f"Committee {name!s} not found", errorcode=404))
 
         roster = sorted(set(committee.committee_members + committee.committers))
@@ -79,19 +79,23 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
         )
         names: dict[str, str | None] = {u.asfuid: u.name for u in user_rows.scalars().all()}
 
-        fingerprints = [k.fingerprint for k in committee.public_signing_keys]
+        fingerprints = [k.fingerprint for k in committee.signing_certificates]
         artifact_counts: dict[str, int] = {}
         if fingerprints:
             via = sql.validate_instrumented_attribute
+            # An artifact is attributed to the key which signed it, so counting per certificate has
+            # to go through SigningKey or everything signed by a subkey is left out
             count_rows = await data.execute(
                 sqlalchemy.select(
-                    via(sql.Artifact.key_fingerprint),
+                    via(sql.SigningKey.certificate_fingerprint),
                     sqlalchemy.func.count(),
                 )
-                .where(via(sql.Artifact.key_fingerprint).in_(fingerprints))
-                .group_by(via(sql.Artifact.key_fingerprint))
+                .select_from(sql.Artifact)
+                .join(sql.SigningKey, via(sql.SigningKey.fingerprint) == via(sql.Artifact.key_fingerprint))
+                .where(via(sql.SigningKey.certificate_fingerprint).in_(fingerprints))
+                .group_by(via(sql.SigningKey.certificate_fingerprint))
             )
-            artifact_counts = {fp: n for fp, n in count_rows.all() if fp is not None}
+            artifact_counts = {fp: n for fp, n in count_rows.all()}
 
         signing_committees = await interaction.automated_release_signing_committees(data)
 
@@ -104,7 +108,7 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
         project.committee = committee
     project_list.sort(key=lambda p: interaction.project_order_key(p, latest_releases.get(str(p.key))))
     signing_keys = sorted(
-        committee.public_signing_keys,
+        committee.signing_certificates,
         key=lambda k: (k.apache_uid or "", k.fingerprint[-16:]),
     )
     committee_member = False
diff --git a/atr/get/keys.py b/atr/get/keys.py
index ff80becc..d929e31b 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -22,6 +22,7 @@ from typing import Literal
 import htpy
 import quart
 import sqlalchemy
+import sqlmodel
 
 import atr.blueprints.get as get
 import atr.db as db
@@ -110,6 +111,18 @@ async def details(session: web.Committer, _keys_details: Literal["keys/details"]
         if is_owner:
             project_list = session.member_committees + session.participant_committees
             user_committees = await data.committee(name_in=project_list).all()
+        via = sql.validate_instrumented_attribute
+        signing_keys = list(
+            (
+                await data.execute(
+                    sqlmodel.select(sql.SigningKey)
+                    .where(via(sql.SigningKey.certificate_fingerprint) == key.fingerprint)
+                    .order_by(via(sql.SigningKey.is_primary).desc(), via(sql.SigningKey.created))
+                )
+            )
+            .scalars()
+            .all()
+        )
 
     if isinstance(key.ascii_armored_key, bytes):
         key.ascii_armored_key = key.ascii_armored_key.decode("utf-8", errors="replace")
@@ -125,36 +138,14 @@ async def details(session: web.Committer, _keys_details: Literal["keys/details"]
 
     _add_row("Fingerprint", key.fingerprint.upper())
 
-    algorithm_name = shared.algorithms[key.algorithm]
-    _add_row("Type", f"{algorithm_name} ({key.length} bits)")
-
-    _add_row("Created", key.created.strftime("%Y-%m-%d %H:%M:%S"))
+    primary_key = next((signing_key for signing_key in signing_keys if signing_key.is_primary), None)
+    if primary_key is not None:
+        _add_row("Type", f"{shared.algorithms[primary_key.algorithm]} ({primary_key.length} bits)")
+        _add_row("Created", primary_key.created.strftime("%Y-%m-%d %H:%M:%S"))
 
     latest_sig = key.latest_self_signature.strftime("%Y-%m-%d %H:%M:%S") if key.latest_self_signature else "Never"
     _add_row("Latest self signature", latest_sig)
 
-    if key.expires:
-        now = datetime.datetime.now(datetime.UTC)
-        days_until_expiry = (key.expires - now).days
-        expires_str = key.expires.strftime("%Y-%m-%d %H:%M:%S")
-        if days_until_expiry < 0:
-            expires_content = htm.span(".text-danger.fw-bold")[
-                expires_str,
-                " ",
-                htm.span(".badge.bg-danger.text-white.ms-2")["Expired"],
-            ]
-        elif days_until_expiry <= 30:
-            expires_content = htm.span(".text-warning.fw-bold")[
-                expires_str,
-                " ",
-                htm.span(".badge.bg-warning.text-dark.ms-2")[f"Expires in {util.plural(days_until_expiry, 'day')}"],
-            ]
-        else:
-            expires_content = expires_str
-    else:
-        expires_content = "Never"
-    _add_row("Expires", expires_content)
-
     _add_row("Primary UID", key.primary_declared_uid or "-")
     secondary_uids = ", ".join(key.secondary_declared_uids) if key.secondary_declared_uids else "-"
 
@@ -201,6 +192,8 @@ async def details(session: web.Committer, _keys_details: Literal["keys/details"]
 
     page.table(".mb-0.table.border.border-2.table-striped.table-sm")[tbody.collect()]
 
+    _add_signing_keys(page, signing_keys)
+
     page.h2["ASCII armored key"]
     page.pre(".mt-3.border.border-2.p-3")[key.ascii_armored_key]
 
@@ -235,23 +228,27 @@ async def keys(session: web.Committer, _keys: Literal["keys"]) -> str:
     committees_to_query = list(set(session.member_committees + session.participant_committees))
 
     async with db.session() as data:
-        user_keys = await data.public_signing_key(apache_uid=session.uid.lower(), _committees=True).all()
+        user_keys = await data.signing_certificate(apache_uid=session.uid.lower(), _committees=True).all()
         user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
-        user_committees_with_keys = await data.committee(name_in=committees_to_query, _public_signing_keys=True).all()
+        user_committees_with_keys = await data.committee(name_in=committees_to_query, _signing_certificates=True).all()
 
-        all_fingerprints = [k.fingerprint for c in user_committees_with_keys for k in c.public_signing_keys]
+        all_fingerprints = [k.fingerprint for c in user_committees_with_keys for k in c.signing_certificates]
         artifact_counts: dict[str, int] = {}
         if all_fingerprints:
             via = sql.validate_instrumented_attribute
+            # An artifact is attributed to the key which signed it, so counting per certificate has
+            # to go through SigningKey or everything signed by a subkey is left out
             count_rows = await data.execute(
                 sqlalchemy.select(
-                    via(sql.Artifact.key_fingerprint),
+                    via(sql.SigningKey.certificate_fingerprint),
                     sqlalchemy.func.count(),
                 )
-                .where(via(sql.Artifact.key_fingerprint).in_(all_fingerprints))
-                .group_by(via(sql.Artifact.key_fingerprint))
+                .select_from(sql.Artifact)
+                .join(sql.SigningKey, via(sql.SigningKey.fingerprint) == via(sql.Artifact.key_fingerprint))
+                .where(via(sql.SigningKey.certificate_fingerprint).in_(all_fingerprints))
+                .group_by(via(sql.SigningKey.certificate_fingerprint))
             )
-            artifact_counts = {fp: n for fp, n in count_rows.all() if fp is not None}
+            artifact_counts = {fp: n for fp, n in count_rows.all()}
 
     for key in user_keys:
         key.committees.sort(key=lambda c: c.key)
@@ -322,6 +319,34 @@ async def upload(_session: web.Committer, _keys_upload: Literal["keys/upload"])
     return await shared.keys.render_upload_page()
 
 
+def _add_signing_keys(page: htm.Block, signing_keys: list[sql.SigningKey]) -> None:
+    if not signing_keys:
+        return
+    page.h2(".h5.mt-4")["Signing keys"]
+
+    thead = htm.thead[
+        htm.tr[
+            htm.th(".px-2", scope="col")["Key ID"],
+            htm.th(".px-2", scope="col")["Role"],
+            htm.th(".px-2", scope="col")["Created"],
+            htm.th(".px-2", scope="col")["Expires"],
+            htm.th(".px-2", scope="col")["Status"],
+        ]
+    ]
+    tbody = htm.Block(htm.tbody)
+    for signing_key in signing_keys:
+        tbody.append(
+            htm.tr[
+                htm.td(".px-2.text-break")[signing_key.key_id.upper()],
+                htm.td(".px-2")["Primary" if signing_key.is_primary else "Subkey"],
+                htm.td(".px-2")[signing_key.created.strftime("%Y-%m-%d")],
+                htm.td(".px-2")[_signing_key_expiry(signing_key)],
+                htm.td(".px-2")[_signing_key_status(signing_key)],
+            ]
+        )
+    page.div(".table-responsive.mb-4")[htm.table(".table.border.table-striped.table-sm")[thead, tbody.collect()]]
+
+
 async def _committee_keys(
     page: htm.Block,
     user_committees_with_keys: list[sql.Committee],
@@ -334,9 +359,9 @@ async def _committee_keys(
         if not util.committee_is_standing(committee.key):
             page.h3(f"#committee-{committee.key}.mt-3")[committee.display_name or committee.key]
 
-            if committee.public_signing_keys:
+            if committee.signing_certificates:
                 sorted_keys = sorted(
-                    committee.public_signing_keys,
+                    committee.signing_certificates,
                     key=lambda k: (k.apache_uid or "", k.fingerprint[-16:]),
                 )
                 thead = htm.thead[
@@ -395,8 +420,8 @@ async def _committee_keys(
 
 async def _key_and_is_owner(
     data: db.Session, session: web.Committer, fingerprint: str
-) -> tuple[sql.PublicSigningKey, bool]:
-    key = await data.public_signing_key(fingerprint=fingerprint, _committees=True).get()
+) -> tuple[sql.SigningCertificate, bool]:
+    key = await data.signing_certificate(fingerprint=fingerprint, _committees=True).get()
     if not key:
         quart.abort(404, description="OpenPGP key not found")
     key.committees.sort(key=lambda c: c.key)
@@ -422,7 +447,7 @@ async def _key_and_is_owner(
     return key, is_owner
 
 
-async def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None:
+async def _openpgp_keys(page: htm.Block, user_keys: list[sql.SigningCertificate]) -> None:
     page.h3["Your OpenPGP keys"]
     if user_keys:
         thead = htm.thead[
@@ -490,6 +515,32 @@ def _render_committee_checkboxes(
     return row_div.collect()
 
 
+def _signing_key_expiry(signing_key: sql.SigningKey) -> str | htm.Element:
+    if signing_key.expires is None:
+        return "Never"
+    expires_str = signing_key.expires.strftime("%Y-%m-%d")
+    days_until_expiry = (signing_key.expires - datetime.datetime.now(datetime.UTC)).days
+    if days_until_expiry < 0:
+        return htm.span(".text-danger.fw-bold")[expires_str]
+    if days_until_expiry <= 30:
+        return htm.span(".text-warning.fw-bold")[
+            expires_str,
+            " ",
+            htm.span(".badge.bg-warning.text-dark.ms-2")[f"in {util.plural(days_until_expiry, 'day')}"],
+        ]
+    return expires_str
+
+
+def _signing_key_status(signing_key: sql.SigningKey) -> str | htm.Element:
+    if signing_key.revoked:
+        return htm.span(".badge.bg-danger.text-white")["Revoked"]
+    if signing_key.expires is not None and (signing_key.expires <= datetime.datetime.now(datetime.UTC)):
+        return htm.span(".badge.bg-danger.text-white")["Expired"]
+    if not signing_key.can_sign:
+        return htm.span(".badge.bg-secondary.text-white")["Cannot sign"]
+    return htm.span(".badge.bg-success.text-white")["Good"]
+
+
 async def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
     page.h3["Your SSH keys"]
     if user_ssh_keys:
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 0a888ca7..47669fbd 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -264,7 +264,7 @@ async def view(
         project = await data.project(
             key=str(project_key),
             _committee=True,
-            _committee_public_signing_keys=True,
+            _committee_signing_certificates=True,
             _release_policy=True,
             _releases=True,
         ).demand(base.ASFQuartException(f"Project {project_key} not found", errorcode=404))
diff --git a/atr/models/api.py b/atr/models/api.py
index 53063659..694c071e 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -118,7 +118,7 @@ class CommitteeGetResults(schema.Strict):
 
 class CommitteeKeysResults(schema.Strict):
     endpoint: Literal["/committee/keys"] = schema.alias("endpoint")
-    keys: Sequence[sql.PublicSigningKey]
+    keys: Sequence[sql.SigningCertificate]
 
 
 class CommitteeProjectsResults(schema.Strict):
@@ -331,7 +331,7 @@ class KeyDeleteResults(schema.Strict):
 
 class KeyGetResults(schema.Strict):
     endpoint: Literal["/key/get"] = schema.alias("endpoint")
-    key: sql.PublicSigningKey
+    key: sql.SigningCertificate
 
 
 class KeysUploadArgs(schema.Strict):
@@ -341,14 +341,14 @@ class KeysUploadArgs(schema.Strict):
 
 class KeysUploadException(schema.Strict):
     status: Literal["error"] = schema.alias("status")
-    key: sql.PublicSigningKey | None
+    key: sql.SigningCertificate | None
     error: str = schema.example("Error message")
     error_type: str = schema.example("KeysUploadError")
 
 
 class KeysUploadResult(schema.Strict):
     status: Literal["success"] = schema.alias("status")
-    key: sql.PublicSigningKey
+    key: sql.SigningCertificate
 
 
 type KeysUploadOutcome = Annotated[
@@ -370,7 +370,7 @@ class KeysUploadResults(schema.Strict):
 
 class KeysUserResults(schema.Strict):
     endpoint: Literal["/keys/user"] = schema.alias("endpoint")
-    keys: Sequence[sql.PublicSigningKey]
+    keys: Sequence[sql.SigningCertificate]
 
 
 class ProjectGetResults(schema.Strict):
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 12c3b51a..4921fd28 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -628,7 +628,8 @@ class Banner(sqlmodel.SQLModel, table=True):
 # KeyLink:
 class KeyLink(sqlmodel.SQLModel, table=True):
     committee_key: str = sqlmodel.Field(foreign_key="committee.key", primary_key=True)
-    key_fingerprint: str = sqlmodel.Field(foreign_key="publicsigningkey.fingerprint", primary_key=True)
+    # Authorisation is granted to the certificate as a whole, never to an individual signing key
+    key_fingerprint: str = sqlmodel.Field(foreign_key="signingcertificate.fingerprint", primary_key=True)
 
 
 # Notification:
@@ -879,7 +880,7 @@ class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
 # SQL core models
 
 
-# Committee: Committee Project PublicSigningKey
+# Committee: Committee Project SigningCertificate
 class Committee(sqlmodel.SQLModel, table=True):
     key: str = sqlmodel.Field(unique=True, primary_key=True, **example("example"))
     name: str | None = sqlmodel.Field(default=None, **example("Example"))
@@ -919,14 +920,14 @@ class Committee(sqlmodel.SQLModel, table=True):
         default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False), **example(["wave"])
     )
 
-    # M-M: Committee -> [PublicSigningKey]
-    # M-M: PublicSigningKey -> [Committee]
-    public_signing_keys: list["PublicSigningKey"] = sqlmodel.Relationship(
+    # M-M: Committee -> [SigningCertificate]
+    # M-M: SigningCertificate -> [Committee]
+    signing_certificates: list["SigningCertificate"] = sqlmodel.Relationship(
         back_populates="committees",
         link_model=KeyLink,
         sa_relationship_kwargs={
-            "secondaryjoin": "and_(PublicSigningKey.fingerprint == KeyLink.key_fingerprint,"
-            " PublicSigningKey.deleted.is_(None))",
+            "secondaryjoin": "and_(SigningCertificate.fingerprint == KeyLink.key_fingerprint,"
+            " SigningCertificate.deleted.is_(None))",
         },
     )
 
@@ -1867,27 +1868,17 @@ class Distribution(sqlmodel.SQLModel, table=True):
 #     see_also(Project.distribution_channels)
 
 
-# PublicSigningKey: Committee
-class PublicSigningKey(sqlmodel.SQLModel, table=True):
-    # The fingerprint must be stored as lowercase hex
+# SigningCertificate: Committee SigningKey
+class SigningCertificate(sqlmodel.SQLModel, table=True):
+    # The fingerprint of the certificate's primary key, stored as lowercase hex. The keys which can
+    # actually sign, this primary among them, are the SigningKey rows hanging off this row
     fingerprint: str = sqlmodel.Field(
         primary_key=True, unique=True, **example("0123456789abcdef0123456789abcdef01234567")
     )
-    # The algorithm is an RFC 4880 algorithm ID
-    algorithm: int = sqlmodel.Field(**example(1))
-    # Key length in bits
-    length: int = sqlmodel.Field(**example(4096))
-    # Creation date
-    created: datetime.datetime = sqlmodel.Field(
-        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
-        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
-    )
     # Latest self signature
     latest_self_signature: datetime.datetime | None = sqlmodel.Field(
         default=None, sa_column=sqlalchemy.Column(UTCDateTime)
     )
-    # Expiration date
-    expires: datetime.datetime | None = sqlmodel.Field(default=None, sa_column=sqlalchemy.Column(UTCDateTime))
     # The primary UID declared in the key
     primary_declared_uid: str | None = sqlmodel.Field(**example("User <us...@example.org>"))
     # The secondary UIDs declared in the key
@@ -1905,17 +1896,57 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
     deleted: datetime.datetime | None = sqlmodel.Field(default=None, sa_column=sqlalchemy.Column(UTCDateTime))
     historic_use: bool = sqlmodel.Field(default=False)
 
-    # M-M: PublicSigningKey -> [Committee]
-    # M-M: Committee -> [PublicSigningKey]
-    committees: list[Committee] = sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
+    # M-M: SigningCertificate -> [Committee]
+    # M-M: Committee -> [SigningCertificate]
+    committees: list[Committee] = sqlmodel.Relationship(back_populates="signing_certificates", link_model=KeyLink)
 
-    def model_post_init(self, _context):
-        if isinstance(self.created, str):
-            self.created = datetime.datetime.fromisoformat(self.created.rstrip("Z"))
+    # 1-M: SigningCertificate -> [SigningKey]
+    signing_keys: list["SigningKey"] = sqlmodel.Relationship(back_populates="certificate")
 
+    def model_post_init(self, _context):
         if isinstance(self.latest_self_signature, str):
             self.latest_self_signature = datetime.datetime.fromisoformat(self.latest_self_signature.rstrip("Z"))
 
+
+# SigningKey: SigningCertificate
+class SigningKey(sqlmodel.SQLModel, table=True):
+    # A key which can carry a signature, being either a certificate's primary key or one of its
+    # subkeys. Its expiry, revocation and signing capability are its own, distinct from the primary's
+    fingerprint: str = sqlmodel.Field(
+        primary_key=True, unique=True, **example("0123456789abcdef0123456789abcdef01234567")
+    )
+    certificate_fingerprint: str = sqlmodel.Field(
+        foreign_key="signingcertificate.fingerprint",
+        ondelete="CASCADE",
+        index=True,
+        **example("0123456789abcdef0123456789abcdef01234567"),
+    )
+    # True for the certificate's own primary key, of which there is exactly one per certificate
+    is_primary: bool = sqlmodel.Field(default=False, index=True)
+    # The legacy 16 hex digit key identifier, which signatures may cite instead of a fingerprint
+    key_id: str = sqlmodel.Field(index=True, **example("0123456789abcdef"))
+    # The algorithm is an RFC 4880 algorithm ID
+    algorithm: int = sqlmodel.Field(**example(1))
+    # Key length in bits
+    length: int = sqlmodel.Field(**example(4096))
+    created: datetime.datetime = sqlmodel.Field(
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
+    )
+    # This key's own expiry, counted from its own creation
+    expires: datetime.datetime | None = sqlmodel.Field(default=None, sa_column=sqlalchemy.Column(UTCDateTime))
+    # True where this key carries a revocation, or hangs beneath a primary which does
+    revoked: bool = sqlmodel.Field(default=False)
+    # False only where the key declares capabilities which exclude signing
+    can_sign: bool = sqlmodel.Field(default=True)
+
+    # M-1: SigningKey -> SigningCertificate
+    certificate: SigningCertificate = sqlmodel.Relationship(back_populates="signing_keys")
+
+    def model_post_init(self, _context):
+        if isinstance(self.created, str):
+            self.created = datetime.datetime.fromisoformat(self.created.rstrip("Z"))
+
         if isinstance(self.expires, str):
             self.expires = datetime.datetime.fromisoformat(self.expires.rstrip("Z"))
 
@@ -2009,10 +2040,10 @@ class Artifact(sqlmodel.SQLModel, table=True):
     release_key: str | None = sqlmodel.Field(
         default=None, foreign_key="release.key", ondelete="CASCADE", index=True, **example("example-0.0.1")
     )
-    # Fingerprint of the GPG public key used to sign the artifact
+    # Fingerprint of the key which signed the artifact, which is a subkey wherever one was used
     key_fingerprint: str | None = sqlmodel.Field(
         default=None,
-        foreign_key="publicsigningkey.fingerprint",
+        foreign_key="signingkey.fingerprint",
         ondelete="RESTRICT",
         index=True,
         **example("0123456789abcdef0123456789abcdef01234567"),
diff --git a/atr/pgp.py b/atr/pgp.py
index 14e9d850..a291e88a 100644
--- a/atr/pgp.py
+++ b/atr/pgp.py
@@ -32,10 +32,27 @@ _SUBKEY_BINDING_SIGNATURE_TYPE: Final[str] = "subkey-binding"
 _SUBKEY_REVOCATION_SIGNATURE_TYPE: Final[str] = "subkey-revocation"
 
 
+@dataclasses.dataclass(frozen=True)
+class SigningKeyFacts:
+    """The stored form of one key which can carry a signature, the primary or one of its subkeys."""
+
+    fingerprint: str
+    key_id: str
+    is_primary: bool
+    algorithm: str
+    length_bits: int | None
+    created: datetime.datetime
+    expires: datetime.datetime | None
+    revoked: bool
+    can_sign: bool
+
+
 @dataclasses.dataclass(frozen=True)
 class SigningKeyStatus:
     # False when the signature names no key we hold, in which case nothing below has been evaluated
     identified: bool
+    # The key which issued the signature, being a subkey wherever one was used, else the primary
+    fingerprint: str | None
     # The earliest expiration across the primary key and the issuing subkey, whichever binds first
     expires: datetime.datetime | None
     # False only when the issuing key declares capabilities which exclude signing
@@ -67,6 +84,70 @@ def latest_self_signature_created_at(key: openpgp.PublicKey) -> datetime.datetim
     return datetime.datetime.fromtimestamp(signature.creation_time, tz=datetime.UTC)
 
 
+def public_params_bits(public_params: openpgp.PublicParamsInfo) -> int | None:
+    for bits in (public_params.rsa_bits, public_params.dsa_bits, public_params.curve_bits):
+        if isinstance(bits, int):
+            return bits
+    return None
+
+
+def revocations_dropped(previous: openpgp.PublicKey, incoming: openpgp.PublicKey) -> set[str]:
+    """Signing keys revoked in `previous` but still present and no longer revoked in `incoming`."""
+    # A revocation is irreversible and survives a minimised re-export, so a signing key coming back from
+    # revoked to live is a stripped block. A key dropped altogether is left to the caller
+    incoming_facts = {facts.fingerprint: facts for facts in signing_key_facts(incoming)}
+    dropped = set()
+    for facts in signing_key_facts(previous):
+        if not facts.revoked:
+            continue
+        current = incoming_facts.get(facts.fingerprint)
+        if (current is not None) and (not current.revoked):
+            dropped.add(facts.fingerprint)
+    return dropped
+
+
+def signing_key_facts(key: openpgp.PublicKey) -> list[SigningKeyFacts]:
+    """Every key in a certificate which can carry a signature, the primary first."""
+    primary_revoked = _primary_is_revoked(key)
+    primary_expires = key_expires_at(key)
+    facts = [
+        SigningKeyFacts(
+            fingerprint=key.fingerprint.lower(),
+            key_id=key.key_id.lower(),
+            is_primary=True,
+            algorithm=key.public_key_algorithm,
+            length_bits=public_params_bits(key.public_params),
+            created=datetime.datetime.fromtimestamp(key.created_at, tz=datetime.UTC),
+            expires=primary_expires,
+            revoked=primary_revoked,
+            can_sign=_declares_signing(_effective_self_signature(key)),
+        )
+    ]
+
+    for binding in key.subkey_bindings():
+        binding_signature = _latest_binding_signature(binding)
+        # A subkey is only usable while its own binding and the primary above it both hold
+        expirations = [
+            expiration
+            for expiration in (primary_expires, _subkey_expires_at(binding, binding_signature))
+            if expiration is not None
+        ]
+        facts.append(
+            SigningKeyFacts(
+                fingerprint=binding.fingerprint.lower(),
+                key_id=binding.key_id.lower(),
+                is_primary=False,
+                algorithm=binding.public_key_algorithm,
+                length_bits=public_params_bits(binding.public_params),
+                created=datetime.datetime.fromtimestamp(binding.created_at, tz=datetime.UTC),
+                expires=min(expirations) if expirations else None,
+                revoked=primary_revoked or _subkey_is_revoked(binding),
+                can_sign=_declares_signing(binding_signature),
+            )
+        )
+    return facts
+
+
 def signing_key_status(
     key: openpgp.PublicKey,
     issuer_fingerprints: set[str],
@@ -85,6 +166,7 @@ def signing_key_status(
         ]
         return SigningKeyStatus(
             identified=True,
+            fingerprint=binding.fingerprint.lower(),
             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
@@ -94,6 +176,7 @@ def signing_key_status(
     if _issuer_is_primary(key, issuer_fingerprints, issuer_key_ids):
         return SigningKeyStatus(
             identified=True,
+            fingerprint=key.fingerprint.lower(),
             expires=key_expires_at(key),
             can_sign=_declares_signing(_effective_self_signature(key)),
             revoked=primary_revoked,
@@ -101,7 +184,7 @@ def signing_key_status(
 
     # 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, revoked=False)
+    return SigningKeyStatus(identified=False, fingerprint=None, expires=None, can_sign=False, revoked=False)
 
 
 def _binding_revoked(binding: openpgp.UserBindingInfo, fingerprint: str, key_id: str) -> bool:
@@ -216,6 +299,12 @@ def _latest_signature(signatures: list[openpgp.SignatureInfo]) -> openpgp.Signat
     return max(valid, 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_expired(signature: openpgp.SignatureInfo, now: float) -> bool:
     expiration = signature.signature_expiration_seconds
     if not expiration:
@@ -224,12 +313,6 @@ def _signature_expired(signature: openpgp.SignatureInfo, now: float) -> bool:
     return (creation + expiration) <= now
 
 
-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}
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 6f4b3f26..188bdc29 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -365,13 +365,13 @@ async def _fetch_keys_from_url(keys_url: str) -> str:
         raise base.ASFQuartException(f"Network error while fetching keys: {e}", errorcode=503)
 
 
-async def _flash_openpgp_key_uid_warning(key_model: sql.PublicSigningKey, current_asf_uid: str) -> None:
+async def _flash_openpgp_key_uid_warning(key_model: sql.SigningCertificate, current_asf_uid: str) -> None:
     warning = _openpgp_key_uid_warning(key_model, current_asf_uid)
     if warning is not None:
         await quart.flash(str(warning), "warning")
 
 
-def _openpgp_key_uid_warning(key_model: sql.PublicSigningKey, current_asf_uid: str) -> htm.Element | None:
+def _openpgp_key_uid_warning(key_model: sql.SigningCertificate, current_asf_uid: str) -> htm.Element | None:
     fingerprint_upper = key_model.fingerprint.upper()
     if key_model.apache_uid is None:
         return None
diff --git a/atr/storage/datatypes.py b/atr/storage/datatypes.py
index fc39a5d9..0ba4c71a 100644
--- a/atr/storage/datatypes.py
+++ b/atr/storage/datatypes.py
@@ -53,7 +53,7 @@ class KeyStatus(enum.Flag):
 
 class Key(schema.Strict):
     status: KeyStatus
-    key_model: sql.PublicSigningKey
+    key_model: sql.SigningCertificate
     member_ids: list[str] = schema.factory(list)
 
 
@@ -72,7 +72,7 @@ class KeyAssociationUpdate:
 
 @dataclasses.dataclass
 class KeyDeletion:
-    key: sql.PublicSigningKey
+    key: sql.SigningCertificate
     publications: dict[str, outcome.Outcome[KeysPublish]]
 
 
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 3f33f74c..8d8d95eb 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -609,7 +609,20 @@ class ReleaseManager(CommitteeParticipant):
             payload = result.data if isinstance(result.data, dict) else {}
             candidate = payload.get("fingerprint")
             if isinstance(candidate, str) and (stripped := candidate.strip()):
-                return stripped.lower()
+                fingerprint = stripped.lower()
+                # A signer's SigningKey rows are derived when its key is imported, so this is not
+                # expected to be reached; it only stops a key we somehow cannot model from failing the
+                # whole announce
+                via = sql.validate_instrumented_attribute
+                modelled = await self.__data.execute(
+                    sqlmodel.select(via(sql.SigningKey.fingerprint)).where(
+                        via(sql.SigningKey.fingerprint) == fingerprint
+                    )
+                )
+                if modelled.scalar_one_or_none() is not None:
+                    return fingerprint
+                log.warning(f"{fingerprint} verified but has no SigningKey row; recording the artifact unattributed")
+                return None
         return None
 
     async def __write_artifact_rows(
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 4a244427..f7f8f37b 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -98,19 +98,83 @@ def _algorithm_name(algorithm: int) -> str:
 
 
 def _key_length(key: openpgp.PublicKey) -> int:
-    public_params = key.public_params
-    rsa_bits = public_params.rsa_bits
-    if isinstance(rsa_bits, int):
-        return rsa_bits
-    dsa_bits = public_params.dsa_bits
-    if isinstance(dsa_bits, int):
-        return dsa_bits
-    curve_bits = public_params.curve_bits
-    if isinstance(curve_bits, int):
-        return curve_bits
-    raise ValueError(
-        f"Key size is not available for algorithm {key.public_key_algorithm}:"
-        f" rsa_bits={rsa_bits!r}, dsa_bits={dsa_bits!r}, curve_bits={curve_bits!r}"
+    bits = pgp.public_params_bits(key.public_params)
+    if bits is None:
+        raise ValueError(f"Key size is not available for algorithm {key.public_key_algorithm}")
+    return bits
+
+
+def _block_downgrade_reason(stored_block: str, incoming_block: str) -> str | None:
+    # Self-signatures are append-only, so a re-upload may add declarations but must not roll the
+    # effective state back: no revocation dropped, no regression to an older self-signature. A minimised
+    # re-export keeps both and passes. A block we can't parse can't be judged, so allow it through
+    try:
+        stored_key, _ = openpgp.PublicKey.from_armor(stored_block)
+        incoming_key, _ = openpgp.PublicKey.from_armor(incoming_block)
+    except Exception:
+        return None
+    dropped = pgp.revocations_dropped(stored_key, incoming_key)
+    if dropped:
+        return f"drops the revocation on {', '.join(sorted(dropped))}"
+    stored_latest = pgp.latest_self_signature_created_at(stored_key)
+    incoming_latest = pgp.latest_self_signature_created_at(incoming_key)
+    if (stored_latest is not None) and ((incoming_latest is None) or (incoming_latest < stored_latest)):
+        return "predates the latest stored self-signature"
+    return None
+
+
+async def _sync_signing_keys(data: db.Session, certificates: list[sql.SigningCertificate]) -> None:
+    """Bring each certificate's SigningKey rows into line with the block it holds"""
+    rows = []
+    for certificate in certificates:
+        block = certificate.ascii_armored_key
+        if isinstance(block, bytes):
+            block = block.decode("utf-8", errors="replace")
+        try:
+            key, _ = openpgp.PublicKey.from_armor(block)
+        except Exception as e:
+            log.warning(f"Could not derive signing keys for {certificate.fingerprint}: {e}")
+            continue
+        if key.fingerprint.lower() != certificate.fingerprint.lower():
+            # The block belongs to another key, so anything derived from it would describe that one
+            log.warning(f"Stored block for {certificate.fingerprint} holds {key.fingerprint.lower()}, skipping")
+            continue
+        for facts in pgp.signing_key_facts(key):
+            rows.append(
+                {
+                    "fingerprint": facts.fingerprint,
+                    "certificate_fingerprint": certificate.fingerprint.lower(),
+                    "is_primary": facts.is_primary,
+                    "key_id": facts.key_id,
+                    "algorithm": _algorithm_id(facts.algorithm),
+                    "length": facts.length_bits or 0,
+                    "created": facts.created,
+                    "expires": facts.expires,
+                    "revoked": facts.revoked,
+                    "can_sign": facts.can_sign,
+                }
+            )
+    if not rows:
+        return
+    statement = sqlite.insert(sql.SigningKey)
+    # A refreshed block can move expiry, revoke a subkey, or add one, so every column is rewritten.
+    # Subkeys which have disappeared keep their row, since an artifact may still be attributed to one
+    await data.execute(
+        statement.on_conflict_do_update(
+            index_elements=["fingerprint"],
+            set_={
+                "certificate_fingerprint": statement.excluded.certificate_fingerprint,
+                "is_primary": statement.excluded.is_primary,
+                "key_id": statement.excluded.key_id,
+                "algorithm": statement.excluded.algorithm,
+                "length": statement.excluded.length,
+                "created": statement.excluded.created,
+                "expires": statement.excluded.expires,
+                "revoked": statement.excluded.revoked,
+                "can_sign": statement.excluded.can_sign,
+            },
+        ),
+        rows,
     )
 
 
@@ -144,18 +208,18 @@ class FoundationCommitter(GeneralPublic):
     async def delete_key(self, fingerprint: str) -> outcome.Outcome[datatypes.KeyDeletion]:
         try:
             via = sql.validate_instrumented_attribute
-            key = await self.__data.public_signing_key(
+            key = await self.__data.signing_certificate(
                 fingerprint=fingerprint,
                 apache_uid=self.__asf_uid,
                 _committees=True,
             ).demand(storage.AccessError(f"Key not found: {fingerprint}", status=404))
             affected_committee_keys = {committee.key for committee in key.committees}
             update_result = await self.__data.execute(
-                sqlmodel.update(sql.PublicSigningKey)
+                sqlmodel.update(sql.SigningCertificate)
                 .where(
-                    via(sql.PublicSigningKey.fingerprint) == key.fingerprint,
-                    via(sql.PublicSigningKey.apache_uid) == self.__asf_uid,
-                    via(sql.PublicSigningKey.deleted).is_(None),
+                    via(sql.SigningCertificate.fingerprint) == key.fingerprint,
+                    via(sql.SigningCertificate.apache_uid) == self.__asf_uid,
+                    via(sql.SigningCertificate.deleted).is_(None),
                 )
                 .values(deleted=datetime.datetime.now(datetime.UTC))
             )
@@ -187,7 +251,7 @@ class FoundationCommitter(GeneralPublic):
         key: openpgp.PublicKey,
         ldap_data: cache.EmailUidLookup,
         original_key_block: str | None = None,
-    ) -> sql.PublicSigningKey:
+    ) -> sql.SigningCertificate:
         uids = list(key.user_ids)
         asf_uid = self.__uids_asf_uid(uids, ldap_data)
         if not uids:
@@ -195,15 +259,10 @@ class FoundationCommitter(GeneralPublic):
 
         # Use the original key block if available
         ascii_armored = original_key_block if original_key_block else key.to_armored()
-        expires_at = pgp.key_expires_at(key)
 
-        return sql.PublicSigningKey(
+        return sql.SigningCertificate(
             fingerprint=key.fingerprint.lower(),
-            algorithm=_algorithm_id(key.public_key_algorithm),
-            length=_key_length(key),
-            created=datetime.datetime.fromtimestamp(key.created_at, tz=datetime.UTC),
             latest_self_signature=pgp.latest_self_signature_created_at(key),
-            expires=expires_at,
             primary_declared_uid=uids[0],
             secondary_declared_uids=uids[1:],
             apache_uid=asf_uid,
@@ -211,16 +270,16 @@ class FoundationCommitter(GeneralPublic):
         )
 
     async def keys_file_text(self, committee_key: str) -> str:
-        committee = await self.__data.committee(key=committee_key, _public_signing_keys=True).demand(
+        committee = await self.__data.committee(key=committee_key, _signing_certificates=True).demand(
             storage.AccessError(f"Committee not found: {committee_key}", status=404)
         )
         return await self._keys_file_text(committee)
 
     async def _keys_file_text(self, committee: sql.Committee) -> str:
-        if not committee.public_signing_keys:
+        if not committee.signing_certificates:
             raise storage.AccessError(f"No keys found for committee {committee.key} to generate KEYS file.", status=404)
 
-        sorted_keys = sorted(committee.public_signing_keys, key=lambda k: k.fingerprint)
+        sorted_keys = sorted(committee.signing_certificates, key=lambda k: k.fingerprint)
 
         keys_content_list = []
         for key in sorted_keys:
@@ -248,7 +307,7 @@ class FoundationCommitter(GeneralPublic):
             keys_content_list.append(armored_key)
 
         key_blocks_str = "\n\n\n".join(keys_content_list) + "\n"
-        key_count_for_header = len(committee.public_signing_keys)
+        key_count_for_header = len(committee.signing_certificates)
 
         return await self.__keys_file_format(
             committee_key=committee.key,
@@ -300,13 +359,13 @@ class FoundationCommitter(GeneralPublic):
     async def _sync_committee_keys_file(
         self, committee_key: str
     ) -> tuple[str | None, outcome.Outcome[datatypes.KeysPublish]]:
-        committee = await self.__data.committee(key=committee_key, _public_signing_keys=True).demand(
+        committee = await self.__data.committee(key=committee_key, _signing_certificates=True).demand(
             storage.AccessError(f"Committee not found: {committee_key}", status=404)
         )
         committee_keys_path = self._committee_keys_path(committee)
         committee_keys_dir = committee_keys_path.parent
 
-        if not committee.public_signing_keys:
+        if not committee.signing_certificates:
             try:
                 if await aiofiles.os.path.exists(committee_keys_path):
                     await aiofiles.os.remove(committee_keys_path)
@@ -342,7 +401,7 @@ class FoundationCommitter(GeneralPublic):
             return outcome.Error(storage.AccessError("Test key deletion not enabled", status=403))
 
         try:
-            test_user_keys = await self.__data.public_signing_key(
+            test_user_keys = await self.__data.signing_certificate(
                 apache_uid=test_uid, deleted=db.NOT_SET, _committees=True
             ).all()
 
@@ -383,7 +442,7 @@ class FoundationCommitter(GeneralPublic):
     ) -> datatypes.KeyAssociationUpdate:
         via = sql.validate_instrumented_attribute
 
-        key = await self.__data.public_signing_key(
+        key = await self.__data.signing_certificate(
             fingerprint=fingerprint,
             apache_uid=self.__asf_uid,
             _committees=True,
@@ -455,7 +514,7 @@ class FoundationCommitter(GeneralPublic):
     def __block_model_create(self, key_block: str, ldap_data: cache.EmailUidLookup) -> datatypes.Key:
         public_key, _ = openpgp.PublicKey.from_armor(key_block)
         key_model = self.public_key_model(public_key, ldap_data, original_key_block=key_block)
-        _validate_key_strength(key_model.algorithm, key_model.length, key_model.created)
+        _validate_key_strength(public_key)
         return datatypes.Key(
             status=datatypes.KeyStatus.PARSED,
             key_model=key_model,
@@ -472,10 +531,10 @@ class FoundationCommitter(GeneralPublic):
 
         key_values = [key.key_model.model_dump(exclude={"committees"})]
         key_insert_result = await self.__data.execute(
-            sqlite.insert(sql.PublicSigningKey)
+            sqlite.insert(sql.SigningCertificate)
             .values(key_values)
             .on_conflict_do_nothing(index_elements=["fingerprint"])
-            .returning(via(sql.PublicSigningKey.fingerprint))
+            .returning(via(sql.SigningCertificate.fingerprint))
         )
         inserted = key_insert_result.one_or_none() is not None
         undeleted = []
@@ -490,10 +549,14 @@ class FoundationCommitter(GeneralPublic):
             undeleted = await self.__undelete_keys([key.key_model.fingerprint])
             if undeleted and not refreshed:
                 await self.__data.execute(
-                    sqlmodel.update(sql.PublicSigningKey)
-                    .where(via(sql.PublicSigningKey.fingerprint) == key.key_model.fingerprint)
+                    sqlmodel.update(sql.SigningCertificate)
+                    .where(via(sql.SigningCertificate.fingerprint) == key.key_model.fingerprint)
                     .values(apache_uid=key.key_model.apache_uid)
                 )
+        # A refused downgrade kept the stored block, so its rows must not be rebuilt from the block we
+        # declined; only sync when the block actually changed
+        if inserted or refreshed:
+            await _sync_signing_keys(self.__data, [key.key_model])
         await self.__data.commit()
 
         if undeleted:
@@ -609,15 +672,16 @@ 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()
+    async def __refresh_stored_key(self, model: sql.SigningCertificate) -> bool:
+        existing = await self.__data.signing_certificate(fingerprint=model.fingerprint, deleted=db.NOT_SET).get()
         if (existing is None) or (existing.ascii_armored_key == model.ascii_armored_key):
             return False
+        downgrade = _block_downgrade_reason(existing.ascii_armored_key, model.ascii_armored_key)
+        if downgrade is not None:
+            log.warning(f"Not refreshing {model.fingerprint}: the uploaded block {downgrade}")
+            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
@@ -638,8 +702,8 @@ and was published by the committee.\
             return []
         flagged = sorted(fingerprint for fingerprint, member_ids in member_map.items() if member_ids & matched)
         await self.__data.execute(
-            sqlmodel.update(sql.PublicSigningKey)
-            .where(via(sql.PublicSigningKey.fingerprint).in_(flagged))
+            sqlmodel.update(sql.SigningCertificate)
+            .where(via(sql.SigningCertificate.fingerprint).in_(flagged))
             .values(historic_use=True)
         )
         await self.__data.execute(
@@ -702,13 +766,13 @@ and was published by the committee.\
             return []
         via = sql.validate_instrumented_attribute
         result = await self.__data.execute(
-            sqlmodel.update(sql.PublicSigningKey)
+            sqlmodel.update(sql.SigningCertificate)
             .where(
-                via(sql.PublicSigningKey.fingerprint).in_(sorted(fingerprints)),
-                via(sql.PublicSigningKey.deleted).is_not(None),
+                via(sql.SigningCertificate.fingerprint).in_(sorted(fingerprints)),
+                via(sql.SigningCertificate.deleted).is_not(None),
             )
             .values(deleted=None)
-            .returning(via(sql.PublicSigningKey.fingerprint))
+            .returning(via(sql.SigningCertificate.fingerprint))
         )
         return sorted(result.scalars().all())
 
@@ -735,7 +799,7 @@ class CommitteeParticipant(FoundationCommitter):
         via = sql.validate_instrumented_attribute
         link_values = [{"committee_key": self.__committee_key, "key_fingerprint": fingerprint}]
         try:
-            await self.__data.public_signing_key(fingerprint=fingerprint).demand(
+            await self.__data.signing_certificate(fingerprint=fingerprint).demand(
                 storage.AccessError(f"Key not found: {fingerprint}", status=404)
             )
             link_insert_result = await self.__data.execute(
@@ -780,7 +844,7 @@ class CommitteeParticipant(FoundationCommitter):
         )
         try:
             committee = await self.committee()
-            if not committee.public_signing_keys:
+            if not committee.signing_certificates:
                 return outcome.Error(no_keys), outcome.Error(no_keys)
             synced_path, svn_outcome = await self._sync_committee_keys_file(self.__committee_key)
         except Exception as e:
@@ -790,7 +854,7 @@ class CommitteeParticipant(FoundationCommitter):
         return outcome.Result(synced_path), svn_outcome
 
     async def committee(self) -> sql.Committee:
-        return await self.__data.committee(key=self.__committee_key, _public_signing_keys=True).demand(
+        return await self.__data.committee(key=self.__committee_key, _signing_certificates=True).demand(
             storage.AccessError(f"Committee not found: {self.__committee_key}", status=404)
         )
 
@@ -876,7 +940,7 @@ class CommitteeParticipant(FoundationCommitter):
         key_list = []
         try:
             key_model = self.public_key_model(public_key, ldap_data, original_key_block=key_block)
-            _validate_key_strength(key_model.algorithm, key_model.length, key_model.created)
+            _validate_key_strength(public_key)
             key = datatypes.Key(
                 status=datatypes.KeyStatus.PARSED,
                 key_model=key_model,
@@ -924,9 +988,9 @@ class CommitteeParticipant(FoundationCommitter):
         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)))
+                    via(sql.SigningCertificate.fingerprint),
+                    via(sql.SigningCertificate.ascii_armored_key),
+                ).where(via(sql.SigningCertificate.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,
@@ -936,8 +1000,20 @@ class CommitteeParticipant(FoundationCommitter):
             for fingerprint, block in incoming_blocks.items()
             if (fingerprint in stored_blocks) and (stored_blocks[fingerprint] != block)
         }
+        # A re-import must not roll a held key's effective state back, so leave any block which would do
+        # so untouched rather than overwrite the stored one
+        downgraded = {
+            fingerprint
+            for fingerprint in refreshed_fingerprints
+            if _block_downgrade_reason(stored_blocks[fingerprint], incoming_blocks[fingerprint]) is not None
+        }
+        if downgraded:
+            kept = ", ".join(sorted(downgraded))
+            log.warning(f"Keeping the stored block for {util.plural(len(downgraded), 'key')}: {kept}")
+            key_list = [key for key in key_list if key.key_model.fingerprint not in downgraded]
+            key_values = [values for values in key_values if values["fingerprint"] not in downgraded]
         if key_values:
-            stmt = sqlite.insert(sql.PublicSigningKey).values(key_values)
+            stmt = sqlite.insert(sql.SigningCertificate).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
@@ -949,13 +1025,12 @@ class CommitteeParticipant(FoundationCommitter):
                     "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)),
+                stmt.returning(via(sql.SigningCertificate.fingerprint)),
             )
             key_inserts = {row.fingerprint for row in key_insert_result}
             log.info(f"Inserted or updated {util.plural(len(key_inserts), 'key')}")
@@ -972,11 +1047,14 @@ class CommitteeParticipant(FoundationCommitter):
         outcomes.update_roes(Exception, replace_with_inserted)
 
         persisted_fingerprints = {v["fingerprint"] for v in key_values}
+        # An artifact points at the SigningKey which signed it, so these rows must exist before an
+        # artifact can be attributed
+        await _sync_signing_keys(self.__data, [key.key_model for key in key_list])
         undeleted = await self.__undelete_keys(sorted(persisted_fingerprints))
         await self.__signature_hints_consume(key_list)
         await self.__data.flush()
 
-        existing_fingerprints = {k.fingerprint for k in committee.public_signing_keys}
+        existing_fingerprints = {k.fingerprint for k in committee.signing_certificates}
         new_fingerprints = persisted_fingerprints - existing_fingerprints
         link_inserts = set()
         if new_fingerprints and associate:
@@ -1022,7 +1100,7 @@ class CommitteeParticipant(FoundationCommitter):
                 fingerprints=[f for f in undeleted],
             )
             publications = await self.__sync_committees_for_keys(undeleted)
-        if link_inserts or refreshed_fingerprints:
+        if link_inserts or (refreshed_fingerprints - downgraded):
             await self._recheck_committee_drafts(self.__committee_key)
         return outcomes, publications
 
@@ -1069,8 +1147,8 @@ class CommitteeParticipant(FoundationCommitter):
             return []
         flagged = sorted(fingerprint for fingerprint, member_ids in member_map.items() if member_ids & matched)
         await self.__data.execute(
-            sqlmodel.update(sql.PublicSigningKey)
-            .where(via(sql.PublicSigningKey.fingerprint).in_(flagged))
+            sqlmodel.update(sql.SigningCertificate)
+            .where(via(sql.SigningCertificate.fingerprint).in_(flagged))
             .values(historic_use=True)
         )
         await self.__data.execute(
@@ -1102,13 +1180,13 @@ class CommitteeParticipant(FoundationCommitter):
             return []
         via = sql.validate_instrumented_attribute
         result = await self.__data.execute(
-            sqlmodel.update(sql.PublicSigningKey)
+            sqlmodel.update(sql.SigningCertificate)
             .where(
-                via(sql.PublicSigningKey.fingerprint).in_(sorted(fingerprints)),
-                via(sql.PublicSigningKey.deleted).is_not(None),
+                via(sql.SigningCertificate.fingerprint).in_(sorted(fingerprints)),
+                via(sql.SigningCertificate.deleted).is_not(None),
             )
             .values(deleted=None)
-            .returning(via(sql.PublicSigningKey.fingerprint))
+            .returning(via(sql.SigningCertificate.fingerprint))
         )
         return sorted(result.scalars().all())
 
@@ -1200,10 +1278,10 @@ class FoundationAdmin(CommitteeMember):
         num_deleted = 0
         if orphaned:
             update_result = await self.__data.execute(
-                sqlmodel.update(sql.PublicSigningKey)
+                sqlmodel.update(sql.SigningCertificate)
                 .where(
-                    via(sql.PublicSigningKey.fingerprint).in_(orphaned),
-                    via(sql.PublicSigningKey.deleted).is_(None),
+                    via(sql.SigningCertificate.fingerprint).in_(orphaned),
+                    via(sql.SigningCertificate.deleted).is_(None),
                 )
                 .values(deleted=datetime.datetime.now(datetime.UTC))
             )
@@ -1235,8 +1313,11 @@ class FoundationAdmin(CommitteeMember):
         return (num_unlinked, num_deleted, publication)
 
 
-def _validate_key_strength(algorithm: int, length: int, created: datetime.datetime) -> None:
+def _validate_key_strength(key: openpgp.PublicKey) -> None:
     """Raise ValueError if the key is recently-generated and does not meet the minimum cryptographic strength."""
+    algorithm = _algorithm_id(key.public_key_algorithm)
+    length = _key_length(key)
+    created = datetime.datetime.fromtimestamp(key.created_at, tz=datetime.UTC)
     if created > datetime.datetime(2026, 4, 1, 0, 0, 0, tzinfo=datetime.UTC):
         if algorithm not in _APPROVED_KEY_ALGORITHMS:
             raise ValueError(
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index 835769a5..4f00c295 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -482,12 +482,12 @@ async def _resolve_committee_signing_keys(release: sql.Release, rel_path: str |
         statement = (
             sqlmodel.select(
                 via(sql.KeyLink.key_fingerprint),
-                via(sql.PublicSigningKey.ascii_armored_key),
+                via(sql.SigningCertificate.ascii_armored_key),
             )
-            .join(sql.PublicSigningKey)
+            .join(sql.SigningCertificate)
             .where(
                 via(sql.KeyLink.committee_key) == committee_key,
-                via(sql.PublicSigningKey.deleted).is_(None),
+                via(sql.SigningCertificate.deleted).is_(None),
             )
         )
         result = await data.execute(statement)
@@ -495,7 +495,7 @@ async def _resolve_committee_signing_keys(release: sql.Release, rel_path: str |
 
     # 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 = []
+    entries: list[str] = []
     for fingerprint, block in rows:
         if not fingerprint:
             continue
diff --git a/atr/tasks/checks/signature.py b/atr/tasks/checks/signature.py
index 2c9a22be..53891378 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] = "6"
+CHECK_VERSION: Final[str] = "7"
 
 
 async def check(args: checks.FunctionArguments) -> results.Results | None:
@@ -87,12 +87,12 @@ async def _check_core_logic(committee_key: str, artifact_path: str, signature_pa
     log.info(f"Attempting to fetch keys for committee_key: '{committee_key}'")
     async with db.session() as session:
         statement = (
-            sqlmodel.select(sql.PublicSigningKey)
+            sqlmodel.select(sql.SigningCertificate)
             .join(sql.KeyLink)
             .join(sql.Committee)
             .where(
                 sql.validate_instrumented_attribute(sql.Committee.key) == committee_key,
-                sql.validate_instrumented_attribute(sql.PublicSigningKey.deleted).is_(None),
+                sql.validate_instrumented_attribute(sql.SigningCertificate.deleted).is_(None),
             )
         )
         result = await session.execute(statement)
@@ -186,11 +186,11 @@ def _check_core_logic_verify_signature(
 
     apache_uid_ok = apache_uid_map.get(matched_key.fingerprint.lower(), False)
 
+    status = pgp.signing_key_status(matched_key, issuer_fingerprints, issuer_key_ids)
     refusal = _key_refusal_result(
+        status=status,
         matched_key=matched_key,
         signature_info=signature_info,
-        issuer_fingerprints=issuer_fingerprints,
-        issuer_key_ids=issuer_key_ids,
         num_committee_keys=len(ascii_armored_keys),
         key_has_apache_uid=apache_uid_ok,
     )
@@ -204,6 +204,7 @@ def _check_core_logic_verify_signature(
         valid=True,
         num_committee_keys=len(ascii_armored_keys),
         key_has_apache_uid=apache_uid_ok,
+        signing_key_fingerprint=status.fingerprint,
     )
 
     if not apache_uid_ok:
@@ -223,7 +224,10 @@ def _check_core_logic_verify_signature(
         "key_id": key_id,
         "timestamp": timestamp,
         "username": username,
-        "fingerprint": matched_key.fingerprint.lower(),
+        # The key which actually signed, which is a subkey wherever one was used. Artifact
+        # provenance is recorded from this, so it must not be widened to the certificate
+        "fingerprint": status.fingerprint,
+        "certificate_fingerprint": matched_key.fingerprint.lower(),
         "status": "Valid signature",
         "debug_info": debug_info,
     }
@@ -237,15 +241,18 @@ def _debug_info(
     valid: bool,
     num_committee_keys: int,
     key_has_apache_uid: bool,
+    signing_key_fingerprint: str | None = None,
 ) -> dict[str, Any]:
-    fingerprint = key.fingerprint.lower() if (key is not None) else "Not available"
+    certificate_fingerprint = key.fingerprint.lower() if (key is not None) else "Not available"
+    # key_id below names the issuing key, so the fingerprint beside it has to name the same one
+    fingerprint = signing_key_fingerprint or certificate_fingerprint
     key_id = key.key_id if (key is not None) else "Not available"
     creation_time = signature_info.creation_time if (signature_info is not None) else None
     username = _signer_username(key, signature_info) if ((key is not None) and (signature_info is not None)) else None
     return {
         "key_id": (signature_info.issuer_key_ids[0] if (signature_info and signature_info.issuer_key_ids) else key_id),
         "fingerprint": fingerprint,
-        "pubkey_fingerprint": fingerprint,
+        "pubkey_fingerprint": certificate_fingerprint,
         "creation_date": creation_time if (creation_time is not None) else "Not available",
         "timestamp": creation_time if (creation_time is not None) else "Not available",
         "username": username or "Not available",
@@ -326,15 +333,13 @@ def _key_matches_signature(
 
 def _key_refusal_result(
     *,
+    status: pgp.SigningKeyStatus,
     matched_key: openpgp.PublicKey,
     signature_info: openpgp.SignatureInfo,
-    issuer_fingerprints: set[str],
-    issuer_key_ids: set[str],
     num_committee_keys: int,
     key_has_apache_uid: bool,
 ) -> dict[str, Any] | None:
     """The blocker for a key we won't accept as the signer, or None where the key is acceptable."""
-    status = pgp.signing_key_status(matched_key, issuer_fingerprints, issuer_key_ids)
     if not status.identified:
         return _unidentified_key_result(
             matched_key=matched_key,
diff --git a/atr/tasks/maintenance.py b/atr/tasks/maintenance.py
index ef679dff..09c0c689 100644
--- a/atr/tasks/maintenance.py
+++ b/atr/tasks/maintenance.py
@@ -81,14 +81,14 @@ async def _database_survey_maintenance() -> None:
             page_size = (await data.execute(sqlalchemy.text("PRAGMA page_size"))).scalar_one()
             freelist = (await data.execute(sqlalchemy.text("PRAGMA freelist_count"))).scalar_one()
             counts = {}
-            for table in ("task", "checkresult", "publicsigningkey"):
+            for table in ("task", "checkresult", "signingcertificate"):
                 counts[table] = (await data.execute(sqlalchemy.text(f"SELECT COUNT(*) FROM {table}"))).scalar_one()
     except Exception:
         log.exception("Database survey failed")
         return
     log.resources(
         f"db pages={pages} page_size={page_size} freelist={freelist} task={counts['task']}"
-        f" checkresult={counts['checkresult']} keys={counts['publicsigningkey']}"
+        f" checkresult={counts['checkresult']} keys={counts['signingcertificate']}"
     )
 
 
diff --git a/migrations/versions/0110_2026.07.20_7c41ab93.py b/migrations/versions/0110_2026.07.20_7c41ab93.py
new file mode 100644
index 00000000..d8b199b1
--- /dev/null
+++ b/migrations/versions/0110_2026.07.20_7c41ab93.py
@@ -0,0 +1,171 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Separate signing keys from the certificates which carry them
+
+Revision ID: 0110_2026.07.20_7c41ab93
+Revises: 0109_2026.07.15_91de479d
+Create Date: 2026-07-20 13:16:27.542718+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+import atr.models.sql as sql
+
+# Revision identifiers, used by Alembic
+revision: str = "0110_2026.07.20_7c41ab93"
+down_revision: str | None = "0109_2026.07.15_91de479d"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.rename_table("publicsigningkey", "signingcertificate")
+
+    op.create_table(
+        "signingkey",
+        sa.Column("fingerprint", sa.String(), nullable=False),
+        sa.Column("certificate_fingerprint", sa.String(), nullable=False),
+        sa.Column("is_primary", sa.Boolean(), nullable=False),
+        sa.Column("key_id", sa.String(), nullable=False),
+        sa.Column("algorithm", sa.Integer(), nullable=False),
+        sa.Column("length", sa.Integer(), nullable=False),
+        sa.Column("created", sql.UTCDateTime(timezone=True), nullable=False),
+        sa.Column("expires", sql.UTCDateTime(timezone=True), nullable=True),
+        sa.Column("revoked", sa.Boolean(), nullable=False),
+        sa.Column("can_sign", sa.Boolean(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["certificate_fingerprint"],
+            ["signingcertificate.fingerprint"],
+            name=op.f("fk_signingkey_certificate_fingerprint_signingcertificate"),
+            ondelete="CASCADE",
+        ),
+        sa.PrimaryKeyConstraint("fingerprint", name=op.f("pk_signingkey")),
+        sa.UniqueConstraint("fingerprint", name=op.f("uq_signingkey_fingerprint")),
+    )
+    with op.batch_alter_table("signingkey", schema=None) as batch_op:
+        batch_op.create_index(batch_op.f("ix_signingkey_certificate_fingerprint"), ["certificate_fingerprint"])
+        batch_op.create_index(batch_op.f("ix_signingkey_is_primary"), ["is_primary"])
+        batch_op.create_index(batch_op.f("ix_signingkey_key_id"), ["key_id"])
+
+    # Every certificate contributes its own primary key. Artifact.key_fingerprint is repointed at
+    # this table below, so the rows have to exist before that constraint is rebuilt. Subkeys, and
+    # the revoked and can_sign flags, need the armored block parsed and are left to the backfill
+    op.execute(
+        """
+        INSERT INTO signingkey (
+            fingerprint, certificate_fingerprint, is_primary, key_id,
+            algorithm, length, created, expires, revoked, can_sign
+        )
+        SELECT
+            fingerprint, fingerprint, 1, substr(fingerprint, -16),
+            algorithm, length, created, expires, 0, 1
+        FROM signingcertificate
+        """
+    )
+
+    with op.batch_alter_table("signingcertificate", schema=None) as batch_op:
+        # Algorithm, length, creation and expiry are per-key facts, now carried by the SigningKey rows
+        batch_op.drop_column("algorithm")
+        batch_op.drop_column("length")
+        batch_op.drop_column("created")
+        batch_op.drop_column("expires")
+        # rename_table leaves the constraints under their publicsigningkey names, so bring the unique
+        # one into line with the naming convention the models expect
+        batch_op.drop_constraint("uq_publicsigningkey_fingerprint", type_="unique")
+        batch_op.create_unique_constraint(batch_op.f("uq_signingcertificate_fingerprint"), ["fingerprint"])
+
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.drop_constraint("fk_artifact_key_fingerprint_publicsigningkey", type_="foreignkey")
+        batch_op.create_foreign_key(
+            batch_op.f("fk_artifact_key_fingerprint_signingkey"),
+            "signingkey",
+            ["key_fingerprint"],
+            ["fingerprint"],
+            ondelete="RESTRICT",
+        )
+
+    with op.batch_alter_table("keylink", schema=None) as batch_op:
+        batch_op.drop_constraint("fk_keylink_key_fingerprint_publicsigningkey", type_="foreignkey")
+        batch_op.create_foreign_key(
+            batch_op.f("fk_keylink_key_fingerprint_signingcertificate"),
+            "signingcertificate",
+            ["key_fingerprint"],
+            ["fingerprint"],
+        )
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("keylink", schema=None) as batch_op:
+        batch_op.drop_constraint(op.f("fk_keylink_key_fingerprint_signingcertificate"), type_="foreignkey")
+
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.drop_constraint(op.f("fk_artifact_key_fingerprint_signingkey"), type_="foreignkey")
+
+    with op.batch_alter_table("signingcertificate", schema=None) as batch_op:
+        batch_op.add_column(sa.Column("algorithm", sa.Integer(), nullable=True))
+        batch_op.add_column(sa.Column("length", sa.Integer(), nullable=True))
+        batch_op.add_column(sa.Column("created", sql.UTCDateTime(timezone=True), nullable=True))
+        batch_op.add_column(sa.Column("expires", sql.UTCDateTime(timezone=True), nullable=True))
+        batch_op.drop_constraint(op.f("uq_signingcertificate_fingerprint"), type_="unique")
+        batch_op.create_unique_constraint("uq_publicsigningkey_fingerprint", ["fingerprint"])
+
+    # An artifact signed by a subkey has no row to point at once this table is gone, so send it back
+    # to the certificate which carries that subkey
+    op.execute(
+        """
+        UPDATE artifact
+        SET key_fingerprint = (
+            SELECT certificate_fingerprint FROM signingkey
+            WHERE signingkey.fingerprint = artifact.key_fingerprint
+        )
+        WHERE key_fingerprint IS NOT NULL
+        """
+    )
+    op.execute(
+        """
+        UPDATE signingcertificate
+        SET (algorithm, length, created, expires) = (
+            SELECT algorithm, length, created, expires FROM signingkey
+            WHERE signingkey.certificate_fingerprint = signingcertificate.fingerprint
+              AND signingkey.is_primary = 1
+        )
+        """
+    )
+
+    op.drop_table("signingkey")
+    op.rename_table("signingcertificate", "publicsigningkey")
+
+    with op.batch_alter_table("artifact", schema=None) as batch_op:
+        batch_op.create_foreign_key(
+            "fk_artifact_key_fingerprint_publicsigningkey",
+            "publicsigningkey",
+            ["key_fingerprint"],
+            ["fingerprint"],
+            ondelete="RESTRICT",
+        )
+
+    with op.batch_alter_table("keylink", schema=None) as batch_op:
+        batch_op.create_foreign_key(
+            "fk_keylink_key_fingerprint_publicsigningkey",
+            "publicsigningkey",
+            ["key_fingerprint"],
+            ["fingerprint"],
+        )
diff --git a/scripts/hints_import.py b/scripts/hints_import.py
index a4d23461..591d0a7b 100755
--- a/scripts/hints_import.py
+++ b/scripts/hints_import.py
@@ -157,7 +157,7 @@ def read_old_format_body(data: bytes, off: int, length_type: int) -> tuple[bytes
 async def seed(hints: set[str]) -> None:
     via = sql.validate_instrumented_attribute
     async with db.session() as data:
-        keys = await data.public_signing_key(deleted=db.NOT_SET).all()
+        keys = await data.signing_certificate(deleted=db.NOT_SET).all()
         covered: set[str] = set()
         flagged: list[str] = []
         failed = 0
@@ -179,8 +179,8 @@ async def seed(hints: set[str]) -> None:
                 flagged.append(key.fingerprint)
         for batch in itertools.batched(sorted(flagged), BATCH_SIZE):
             await data.execute(
-                sqlmodel.update(sql.PublicSigningKey)
-                .where(via(sql.PublicSigningKey.fingerprint).in_(batch))
+                sqlmodel.update(sql.SigningCertificate)
+                .where(via(sql.SigningCertificate.fingerprint).in_(batch))
                 .values(historic_use=True)
             )
         existing = {row.hint for row in await data.signature_hint().all()}
diff --git a/tests/unit/test_keys_delete.py b/tests/unit/test_keys_delete.py
index c46a3113..652ce368 100644
--- a/tests/unit/test_keys_delete.py
+++ b/tests/unit/test_keys_delete.py
@@ -75,8 +75,8 @@ async def test_delete_committee_keys_deletes_orphans(sqlite_sessionmaker) -> Non
         num_unlinked, num_deleted, _ = await writer.delete_committee_keys()
 
         assert (num_unlinked, num_deleted) == (2, 1)
-        alpha = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT, deleted=db.NOT_SET).get()
-        beta = await data.public_signing_key(fingerprint=BETA_FINGERPRINT).get()
+        alpha = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT, deleted=db.NOT_SET).get()
+        beta = await data.signing_certificate(fingerprint=BETA_FINGERPRINT).get()
         assert alpha is not None
         assert alpha.deleted is not None
         assert beta is not None
@@ -91,8 +91,8 @@ async def test_delete_key_retains_row_and_links(sqlite_sessionmaker) -> None:
         oc = await writer.delete_key(ALPHA_FINGERPRINT)
 
         oc.result_or_raise()
-        assert await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get() is None
-        key = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT, deleted=True).get()
+        assert await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT).get() is None
+        key = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT, deleted=True).get()
         assert key is not None
         assert key.ascii_armored_key == "armored alpha"
         links = await data.execute(sqlmodel.select(sql.KeyLink).where(sql.KeyLink.key_fingerprint == ALPHA_FINGERPRINT))
@@ -120,7 +120,7 @@ async def test_deleted_key_hidden_from_committee_relationship(sqlite_sessionmake
             (
                 await data.execute(
                     sqlmodel.select(sql.Committee)
-                    .options(orm.selectinload(sql.validate_instrumented_attribute(sql.Committee.public_signing_keys)))
+                    .options(orm.selectinload(sql.validate_instrumented_attribute(sql.Committee.signing_certificates)))
                     .where(sql.validate_instrumented_attribute(sql.Committee.key) == "tooling")
                 )
             )
@@ -128,7 +128,7 @@ async def test_deleted_key_hidden_from_committee_relationship(sqlite_sessionmake
             .one()
         )
 
-        assert [k.fingerprint for k in committee.public_signing_keys] == [BETA_FINGERPRINT]
+        assert [k.fingerprint for k in committee.signing_certificates] == [BETA_FINGERPRINT]
 
 
 async def test_keys_file_text_excludes_deleted(sqlite_sessionmaker) -> None:
@@ -158,7 +158,7 @@ async def test_restrict_prevents_hard_delete_of_referenced_key(sqlite_sessionmak
             )
         )
         await data.commit()
-        key = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
+        key = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT).get()
         assert key is not None
 
         await data.delete(key)
@@ -180,12 +180,12 @@ async def test_reupload_undeletes_and_restores_associations(sqlite_sessionmaker)
 
         assert oc.result_or_raise().status == datatypes.KeyStatus.INSERTED
         assert set(publications) == {"tooling"}
-        row = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
+        row = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT).get()
         assert row is not None
         assert row.deleted is None
-        committee = await data.committee(key="tooling", _public_signing_keys=True).get()
+        committee = await data.committee(key="tooling", _signing_certificates=True).get()
         assert committee is not None
-        assert sorted(k.fingerprint for k in committee.public_signing_keys) == [ALPHA_FINGERPRINT, BETA_FINGERPRINT]
+        assert sorted(k.fingerprint for k in committee.signing_certificates) == [ALPHA_FINGERPRINT, BETA_FINGERPRINT]
 
 
 async def test_reupload_with_a_changed_block_refreshes_it(sqlite_sessionmaker) -> None:
@@ -200,7 +200,7 @@ async def test_reupload_with_a_changed_block_refreshes_it(sqlite_sessionmaker) -
         oc, _publications = 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()
+        row = await data.signing_certificate(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
@@ -229,7 +229,7 @@ async def test_signature_hints_consume_flags_and_deletes(sqlite_sessionmaker) ->
         data.add(sql.SignatureHint(hint="feedfacefeedface"))
         await data.commit()
         writer = _committer_writer(data)
-        key_model = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
+        key_model = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT).get()
         assert key_model is not None
         key = datatypes.Key(
             status=datatypes.KeyStatus.PARSED,
@@ -241,7 +241,7 @@ async def test_signature_hints_consume_flags_and_deletes(sqlite_sessionmaker) ->
         await data.commit()
 
         assert flagged == [ALPHA_FINGERPRINT]
-        refreshed = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
+        refreshed = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT).get()
         assert refreshed is not None
         assert refreshed.historic_use is True
         assert await data.signature_hint(hint="1234567890abcdef").get() is None
@@ -258,7 +258,7 @@ async def test_undelete_keys_clears_deleted(sqlite_sessionmaker) -> None:
         await data.commit()
 
         assert undeleted == [ALPHA_FINGERPRINT]
-        key = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
+        key = await data.signing_certificate(fingerprint=ALPHA_FINGERPRINT).get()
         assert key is not None
         assert key.deleted is None
 
@@ -281,8 +281,20 @@ def _committer_writer(data: db.Session) -> keys_writer.FoundationCommitter:
     return writer
 
 
-def _key(fingerprint: str, apache_uid: str, armored: str) -> sql.PublicSigningKey:
-    return sql.PublicSigningKey(
+def _signing_key(fingerprint: str) -> sql.SigningKey:
+    return sql.SigningKey(
+        fingerprint=fingerprint,
+        certificate_fingerprint=fingerprint,
+        is_primary=True,
+        key_id=fingerprint[-16:],
+        algorithm=1,
+        length=4096,
+        created=datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC),
+    )
+
+
+def _key(fingerprint: str, apache_uid: str, armored: str) -> sql.SigningCertificate:
+    return sql.SigningCertificate(
         fingerprint=fingerprint,
         algorithm=1,
         length=4096,
@@ -298,6 +310,10 @@ async def _seed_keys(data: db.Session) -> None:
     data.add(_key(ALPHA_FINGERPRINT, "alice", "armored alpha"))
     data.add(_key(BETA_FINGERPRINT, "bob", "armored beta"))
     await data.commit()
+    # An artifact is attributed to a signing key rather than to the certificate carrying it
+    data.add(_signing_key(ALPHA_FINGERPRINT))
+    data.add(_signing_key(BETA_FINGERPRINT))
+    await data.commit()
     data.add(sql.KeyLink(committee_key="tooling", key_fingerprint=ALPHA_FINGERPRINT))
     data.add(sql.KeyLink(committee_key="tooling", key_fingerprint=BETA_FINGERPRINT))
     await data.commit()
diff --git a/tests/unit/test_keys_post.py b/tests/unit/test_keys_post.py
index 84fe55e8..aa80b567 100644
--- a/tests/unit/test_keys_post.py
+++ b/tests/unit/test_keys_post.py
@@ -70,8 +70,8 @@ async def test_upload_remote_keys_uses_canonical_committee_url(monkeypatch: pyte
     fetch.assert_awaited_once_with(canonical_url)
 
 
-def _public_key(apache_uid: str | None) -> sql.PublicSigningKey:
-    return sql.PublicSigningKey(
+def _public_key(apache_uid: str | None) -> sql.SigningCertificate:
+    return sql.SigningCertificate(
         fingerprint="fp",
         algorithm=1,
         length=4096,
diff --git a/tests/unit/test_keys_writer.py b/tests/unit/test_keys_writer.py
index be34d7cf..6bd5cde6 100644
--- a/tests/unit/test_keys_writer.py
+++ b/tests/unit/test_keys_writer.py
@@ -27,6 +27,7 @@ import sqlmodel
 
 import atr.db as db
 import atr.models.safe as safe
+import atr.models.sql as sql
 import atr.pgp as pgp
 import atr.storage as storage
 import atr.storage.outcome as outcome
@@ -95,10 +96,10 @@ class MockData:
         self.execute = mock.AsyncMock()
         self.flush = mock.AsyncMock()
 
-    def public_signing_key(self, **_kwargs):
+    def signing_certificate(self, **_kwargs):
         return Query(self._key)
 
-    def committee(self, *, key: str, _public_signing_keys: bool = False):
+    def committee(self, *, key: str, _signing_certificates: bool = False):
         return Query(self._committees_after_commit[key])
 
     def release(self, *_args, **_kwargs):
@@ -112,13 +113,9 @@ async def test_database_add_model_audits_inserted_key():
     insert_result = mock.MagicMock()
     insert_result.one_or_none.return_value = object()
     data.execute.return_value = insert_result
-    key_model = keys_writer.sql.PublicSigningKey(
+    key_model = keys_writer.sql.SigningCertificate(
         fingerprint="fp1",
-        algorithm=1,
-        length=4096,
-        created=datetime.datetime.now(datetime.UTC),
         latest_self_signature=None,
-        expires=None,
         primary_declared_uid="Alice <al...@example.org>",
         secondary_declared_uids=[],
         apache_uid="alice",
@@ -168,7 +165,7 @@ async def test_delete_committee_keys_audits_committed_delete_when_sync_fails(sql
     assert sync_failure_audit["committee_key"] == "alpha"
     assert sync_failure_audit["error"] == error_message
 
-    key = await sqlite_data.public_signing_key(fingerprint="fp1", deleted=True).get()
+    key = await sqlite_data.signing_certificate(fingerprint="fp1", deleted=True).get()
     assert key is not None
 
 
@@ -176,7 +173,7 @@ async def test_delete_committee_keys_audits_committed_delete_when_sync_fails(sql
 async def test_delete_committee_keys_removes_links_and_orphaned_keys(sqlite_data):
     await _seed_committee_key(sqlite_data, "alpha", "fp1")
     sqlite_data.add(keys_writer.sql.Committee(key="beta"))
-    sqlite_data.add(_public_signing_key("fp2", apache_uid="bob"))
+    sqlite_data.add(_signing_certificate("fp2", apache_uid="bob"))
     await sqlite_data.commit()
     sqlite_data.add(keys_writer.sql.KeyLink(committee_key="alpha", key_fingerprint="fp2"))
     sqlite_data.add(keys_writer.sql.KeyLink(committee_key="beta", key_fingerprint="fp2"))
@@ -189,9 +186,9 @@ async def test_delete_committee_keys_removes_links_and_orphaned_keys(sqlite_data
 
     assert num_unlinked == 2
     assert num_deleted == 1
-    orphaned = await sqlite_data.public_signing_key(fingerprint="fp1", deleted=True).get()
+    orphaned = await sqlite_data.signing_certificate(fingerprint="fp1", deleted=True).get()
     assert orphaned is not None
-    shared = await sqlite_data.public_signing_key(fingerprint="fp2").get()
+    shared = await sqlite_data.signing_certificate(fingerprint="fp2").get()
     assert shared is not None
     assert shared.deleted is None
     links = (await sqlite_data.execute(sqlmodel.select(keys_writer.sql.KeyLink))).all()
@@ -259,7 +256,7 @@ async def test_ensure_allows_key_without_apache_uid_for_bulk_import() -> None:
     writer, _write_as = _make_foundation_admin(data, "alpha")
     key = keys_writer.datatypes.Key(
         status=keys_writer.datatypes.KeyStatus.PARSED,
-        key_model=_public_signing_key("fp1", apache_uid=None),
+        key_model=_signing_certificate("fp1", apache_uid=None),
     )
     database_outcomes = outcome.List(outcome.Result(key))
     lookup = keys_writer.cache.EmailUidLookup({})
@@ -293,7 +290,7 @@ async def test_ensure_stored_one_accepts_key_with_apache_uid() -> None:
     writer, _write, _write_as = _make_foundation_committer_with_audit(data)
     key = keys_writer.datatypes.Key(
         status=keys_writer.datatypes.KeyStatus.PARSED,
-        key_model=_public_signing_key("fp1", apache_uid="alice"),
+        key_model=_signing_certificate("fp1", apache_uid="alice"),
     )
     database_outcome = outcome.Result(key)
 
@@ -354,7 +351,7 @@ async def test_ensure_stored_one_rejects_key_without_apache_uid() -> None:
     writer, _write, _write_as = _make_foundation_committer_with_audit(data)
     key = keys_writer.datatypes.Key(
         status=keys_writer.datatypes.KeyStatus.PARSED,
-        key_model=_public_signing_key("fp1", apache_uid=None),
+        key_model=_signing_certificate("fp1", apache_uid=None),
     )
     lookup = keys_writer.cache.EmailUidLookup({})
 
@@ -449,10 +446,8 @@ def test_key_length_returns_dsa_bits() -> None:
     assert length == 3072
 
 
-def test_public_key_model_stores_latest_self_signature_separately_from_expiry() -> None:
+def test_certificate_records_its_latest_self_signature() -> None:
     key, _ = keys_writer.openpgp.PublicKey.from_armor(_EMBEDDED_V4_EXPIRING_KEY_ASC)
-    binding = next(iter(key.user_bindings()))
-    binding_signature = binding.signatures[0]
     data = MockData(None, committees_after_commit={})
     writer, _write, _write_as = _make_foundation_committer_with_audit(data)
 
@@ -465,10 +460,20 @@ def test_public_key_model_stores_latest_self_signature_separately_from_expiry()
     assert key_model.latest_self_signature == datetime.datetime.fromtimestamp(
         latest_self_signature.creation_time, datetime.UTC
     )
-    assert key_model.expires == datetime.datetime.fromtimestamp(
+
+
+def test_expiry_is_recorded_against_the_signing_key_not_the_certificate() -> None:
+    key, _ = keys_writer.openpgp.PublicKey.from_armor(_EMBEDDED_V4_EXPIRING_KEY_ASC)
+    binding = next(iter(key.user_bindings()))
+    binding_signature = binding.signatures[0]
+
+    # Expiry is per key, so it belongs to the SigningKey rows and not to the certificate above them
+    assert not hasattr(sql.SigningCertificate, "expires")
+    primary = next(facts for facts in pgp.signing_key_facts(key) if facts.is_primary)
+
+    assert primary.expires == datetime.datetime.fromtimestamp(
         key.created_at + binding_signature.key_expiration_seconds, datetime.UTC
     )
-    assert key_model.latest_self_signature != key_model.expires
 
 
 @pytest.mark.asyncio
@@ -645,12 +650,12 @@ async def test_update_committee_associations_removal_rewrites_keys_file_with_rem
 
 
 def _committee(
-    key: str, public_signing_keys: list[object], *, is_podling: bool = False, automated_keys_file: bool = True
+    key: str, signing_certificates: list[object], *, is_podling: bool = False, automated_keys_file: bool = True
 ):
     return SimpleNamespace(
         key=key,
         is_podling=is_podling,
-        public_signing_keys=public_signing_keys,
+        signing_certificates=signing_certificates,
         automated_keys_file=automated_keys_file,
     )
 
@@ -708,20 +713,16 @@ def _public_key(
 
 async def _seed_committee_key(data: db.Session, committee_key: str, fingerprint: str) -> None:
     data.add(keys_writer.sql.Committee(key=committee_key))
-    data.add(_public_signing_key(fingerprint, apache_uid="alice"))
+    data.add(_signing_certificate(fingerprint, apache_uid="alice"))
     await data.commit()
     data.add(keys_writer.sql.KeyLink(committee_key=committee_key, key_fingerprint=fingerprint))
     await data.commit()
 
 
-def _public_signing_key(fingerprint: str, apache_uid: str | None) -> keys_writer.sql.PublicSigningKey:
-    return keys_writer.sql.PublicSigningKey(
+def _signing_certificate(fingerprint: str, apache_uid: str | None) -> keys_writer.sql.SigningCertificate:
+    return keys_writer.sql.SigningCertificate(
         fingerprint=fingerprint,
-        algorithm=1,
-        length=4096,
-        created=datetime.datetime.now(datetime.UTC),
         latest_self_signature=None,
-        expires=None,
         primary_declared_uid="Alice <al...@example.org>",
         secondary_declared_uids=[],
         apache_uid=apache_uid,
diff --git a/tests/unit/test_pgp.py b/tests/unit/test_pgp.py
index 02beb687..77740ecb 100644
--- a/tests/unit/test_pgp.py
+++ b/tests/unit/test_pgp.py
@@ -23,6 +23,42 @@ import atr.pgp as pgp
 import tests.unit.pgp_fixtures as pgp_fixtures
 
 
+def _block_without_signature_type(block: str, signature_type: int) -> str:
+    # Re-armour a certificate with every self-signature of a given type removed, which is what a
+    # stripped re-upload looks like at the packet level
+    key, _ = openpgp.PublicKey.from_armor(block)
+    data = key.to_bytes()
+    kept = bytearray()
+    index = 0
+    while index < len(data):
+        start = index
+        header = data[index]
+        index += 1
+        if header & 0x40:
+            tag = header & 0x3F
+            first = data[index]
+            index += 1
+            if first < 192:
+                length = first
+            elif first < 224:
+                length = ((first - 192) << 8) + data[index] + 192
+                index += 1
+            else:
+                length = int.from_bytes(data[index : index + 4], "big")
+                index += 4
+        else:
+            tag = (header >> 2) & 0x0F
+            width = (1, 2, 4)[header & 0x03]
+            length = int.from_bytes(data[index : index + width], "big")
+            index += width
+        body = index
+        index += length
+        if not ((tag == 2) and (data[body] == 4) and (data[body + 1] == signature_type)):
+            kept += data[start:index]
+    stripped = openpgp.PublicKey.from_bytes(bytes(kept))
+    return (stripped[0] if isinstance(stripped, tuple) else stripped).to_armored()
+
+
 def test_latest_self_signature_skips_uid_revocations() -> None:
     key, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_UID_PUBLIC_KEY_ASC)
 
@@ -178,3 +214,35 @@ def test_signing_key_status_reads_capabilities_past_a_revoked_user_id() -> None:
     status = pgp.signing_key_status(key, {pgp_fixtures.REVOKED_UID_FINGERPRINT}, set())
 
     assert status.can_sign is False
+
+
+def test_revocations_dropped_detects_a_revocation_removed_on_re_upload() -> None:
+    stored, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_PRIMARY_PUBLIC_KEY_ASC)
+    incoming, _ = openpgp.PublicKey.from_armor(
+        _block_without_signature_type(pgp_fixtures.REVOKED_PRIMARY_PUBLIC_KEY_ASC, 0x20)
+    )
+
+    dropped = pgp.revocations_dropped(stored, incoming)
+
+    assert pgp_fixtures.REVOKED_PRIMARY_FINGERPRINT in dropped
+
+
+def test_revocations_dropped_permits_a_revocation_added_on_re_upload() -> None:
+    # Gap A relies on a genuine new revocation still being able to arrive, so growth is never a drop
+    stored, _ = openpgp.PublicKey.from_armor(
+        _block_without_signature_type(pgp_fixtures.REVOKED_PRIMARY_PUBLIC_KEY_ASC, 0x20)
+    )
+    incoming, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_PRIMARY_PUBLIC_KEY_ASC)
+
+    assert pgp.revocations_dropped(stored, incoming) == set()
+
+
+def test_revocations_dropped_detects_a_subkey_revocation_removed_on_re_upload() -> None:
+    stored, _ = openpgp.PublicKey.from_armor(pgp_fixtures.REVOKED_SUBKEY_PUBLIC_KEY_ASC)
+    incoming, _ = openpgp.PublicKey.from_armor(
+        _block_without_signature_type(pgp_fixtures.REVOKED_SUBKEY_PUBLIC_KEY_ASC, 0x28)
+    )
+
+    dropped = pgp.revocations_dropped(stored, incoming)
+
+    assert pgp_fixtures.REVOKED_SUBKEY_SIGNING_FINGERPRINT in dropped
diff --git a/tests/unit/test_signature_provenance.py b/tests/unit/test_signature_provenance.py
index c7fae572..2613be1c 100644
--- a/tests/unit/test_signature_provenance.py
+++ b/tests/unit/test_signature_provenance.py
@@ -85,18 +85,18 @@ class MockDBSession:
 
 
 class MockKeyDBSession:
-    def __init__(self, public_signing_keys: list[object]) -> None:
-        self._public_signing_keys = public_signing_keys
+    def __init__(self, signing_certificates: list[object]) -> None:
+        self._signing_certificates = signing_certificates
 
-    def public_signing_key(self, **kwargs: object) -> MockQuery:
+    def signing_certificate(self, **kwargs: object) -> MockQuery:
         fingerprint = kwargs.get("fingerprint")
         if fingerprint is not None:
             match = next(
-                (key for key in self._public_signing_keys if getattr(key, "fingerprint", None) == str(fingerprint)),
+                (key for key in self._signing_certificates if getattr(key, "fingerprint", None) == str(fingerprint)),
                 None,
             )
             return MockQuery(match)
-        return MockQuery(self._public_signing_keys)
+        return MockQuery(self._signing_certificates)
 
 
 def test_args_accepts_scoping_fields() -> None:


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org