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/08/11 15:32:30 UTC

(tooling-trusted-releases) 01/01: #1439 - expose subkeys in more places

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

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

commit 76d4f260d8aaa377884016007797d4e15c646b83
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Aug 11 16:25:31 2026 +0100

    #1439 - expose subkeys in more places
---
 atr/get/committees.py                   | 14 ++++++
 atr/get/keys.py                         | 46 ++++++--------------
 atr/shared/keys.py                      | 75 +++++++++++++++++++++++++++++++++
 atr/templates/committee-view.html       | 12 +++++-
 atr/templates/report-selected-path.html | 11 +++++
 5 files changed, 124 insertions(+), 34 deletions(-)

diff --git a/atr/get/committees.py b/atr/get/committees.py
index e654b5f2..f12a152a 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -71,6 +71,7 @@ async def view(session: web.Committer, _committees: Literal["committees"], name:
             key=str(name),
             _projects=True,
             _signing_certificates=True,
+            _signing_keys=True,
         ).demand(base.ASFQuartException(f"Committee {name!s} not found", errorcode=404))
 
         roster = sorted(set(committee.committee_members + committee.committers))
@@ -111,6 +112,8 @@ async def view(session: web.Committer, _committees: Literal["committees"], name:
         committee.signing_certificates,
         key=lambda k: (k.apache_uid or "", k.fingerprint[-16:]),
     )
+    key_lists = _committee_key_lists(signing_keys)
+    revoked_certificates = {c.fingerprint for c in signing_keys if shared.keys.certificate_all_revoked(c)}
     committee_member = False
     if isinstance(session, web.Committer):
         committee_member = await session.prevent_confusing_ui_display_committee(name, False)
@@ -161,6 +164,8 @@ async def view(session: web.Committer, _committees: Literal["committees"], name:
         roster=roster,
         names=names,
         signing_keys=signing_keys,
+        key_lists=key_lists,
+        revoked_certificates=revoked_certificates,
         artifact_counts=artifact_counts,
         ci_builds_enabled=committee.key in signing_committees,
         algorithms=shared.algorithms,
@@ -178,3 +183,12 @@ async def view(session: web.Committer, _committees: Literal["committees"], name:
         ),
         is_standing=is_standing,
     )
+
+
+def _committee_key_lists(certificates: list[sql.SigningCertificate]) -> dict[str, htm.Element]:
+    key_lists: dict[str, htm.Element] = {}
+    for certificate in certificates:
+        keys_list = shared.keys.signing_keys_list(certificate.signing_keys)
+        if keys_list is not None:
+            key_lists[certificate.fingerprint] = keys_list
+    return key_lists
diff --git a/atr/get/keys.py b/atr/get/keys.py
index f94e7be4..4a611621 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -16,7 +16,6 @@
 # under the License.
 
 
-import datetime
 from typing import Literal
 
 import htpy
@@ -344,8 +343,8 @@ def _add_signing_keys(page: htm.Block, signing_keys: list[sql.SigningKey]) -> No
                 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)],
+                htm.td(".px-2")[shared.keys.signing_key_expiry(signing_key)],
+                htm.td(".px-2")[shared.keys.signing_key_status(signing_key)],
             ]
         )
     page.div(".table-responsive.mb-4")[htm.table(".table.border.table-striped.table-sm")[thead, tbody.collect()]]
@@ -355,7 +354,7 @@ def _certificate_key_id(key: sql.SigningCertificate) -> htm.Element:
     link = htm.a(href=util.as_url(details, fingerprint=key.fingerprint))[key.fingerprint[-16:].upper()]
     # A certificate whose primary and every subkey are revoked can't sign anything, so strike it through
     # and say so, since these tables carry no per-key status column
-    if key.signing_keys and all(signing_key.revoked for signing_key in key.signing_keys):
+    if shared.keys.certificate_all_revoked(key):
         return htm.span[
             htm.span(".text-decoration-line-through")[link],
             " ",
@@ -394,7 +393,7 @@ async def _committee_keys(
                 tbody = htm.Block(htm.tbody)
                 for key in sorted_keys:
                     row = htm.Block(htm.tr)
-                    row.td(".text-break.font-monospace.px-2")[_certificate_key_id(key)]
+                    row.td(".text-break.px-2")[*_key_id_cell_contents(key)]
                     email = util.email_from_uid(key.primary_declared_uid) if key.primary_declared_uid else "-"
                     row.td(".text-break.px-2")[email or "-"]
                     row.td(".text-break.px-2")[key.apache_uid or "-"]
@@ -463,6 +462,15 @@ async def _key_and_is_owner(
     return key, is_owner
 
 
+def _key_id_cell_contents(certificate: sql.SigningCertificate) -> list[htm.Element]:
+    # The certificate key id links through to the details, with the signing keys collapsed beneath it
+    contents = [htm.div(".font-monospace")[_certificate_key_id(certificate)]]
+    keys_list = shared.keys.signing_keys_list(certificate.signing_keys)
+    if keys_list is not None:
+        contents.append(keys_list)
+    return contents
+
+
 async def _openpgp_keys(page: htm.Block, user_keys: list[sql.SigningCertificate]) -> None:
     page.h3["Your OpenPGP keys"]
     if user_keys:
@@ -478,7 +486,7 @@ async def _openpgp_keys(page: htm.Block, user_keys: list[sql.SigningCertificate]
         tbody = htm.Block(htm.tbody)
         for key in user_keys:
             row = htm.Block(htm.tr, classes=".page-user-openpgp-key")
-            row.td(".text-break.px-2.align-middle")[_certificate_key_id(key)]
+            row.td(".text-break.px-2.align-middle")[*_key_id_cell_contents(key)]
             if key.committees:
                 committee_keys = ", ".join([c.key for c in key.committees])
                 row.td(".text-break.px-2.align-middle")[committee_keys]
@@ -529,32 +537,6 @@ 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/shared/keys.py b/atr/shared/keys.py
index aff71430..d61e567a 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -17,6 +17,7 @@
 
 """keys.py"""
 
+import datetime
 from typing import Annotated, Literal
 
 import markupsafe
@@ -24,6 +25,7 @@ import pydantic
 
 import atr.form as form
 import atr.htm as htm
+import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
 import atr.storage.datatypes as datatypes
@@ -160,6 +162,12 @@ type UploadKeysForm = Annotated[
 ]
 
 
+def certificate_all_revoked(certificate: sql.SigningCertificate) -> bool:
+    # Every key on the certificate is revoked, so it can no longer sign anything
+    signing_keys = certificate.signing_keys
+    return bool(signing_keys) and all(signing_key.revoked for signing_key in signing_keys)
+
+
 def publication_added_notice(publications: dict[str, storage.outcome.Outcome[datatypes.KeysPublish]]) -> str | None:
     committees = publication_disabled(publications)
     if not committees:
@@ -260,6 +268,73 @@ async def render_upload_page(
     )
 
 
+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_state(signing_key: sql.SigningKey) -> Literal["revoked", "expired", "cannot_sign", "good"]:
+    # Revocation outranks expiry, which outranks a key that simply can't sign
+    if signing_key.revoked:
+        return "revoked"
+    if signing_key.expires is not None and (signing_key.expires <= datetime.datetime.now(datetime.UTC)):
+        return "expired"
+    if not signing_key.can_sign:
+        return "cannot_sign"
+    return "good"
+
+
+def signing_key_status(signing_key: sql.SigningKey) -> str | htm.Element:
+    state = signing_key_state(signing_key)
+    if state == "revoked":
+        return htm.span(".badge.bg-danger.text-white")["Revoked"]
+    if state == "expired":
+        return htm.span(".badge.bg-danger.text-white")["Expired"]
+    if state == "cannot_sign":
+        return htm.span(".badge.bg-secondary.text-white")["Cannot sign"]
+    return htm.span(".badge.bg-success.text-white")["Good"]
+
+
+def signing_keys_list(signing_keys: list[sql.SigningKey]) -> htm.Element | None:
+    # A collapsed breakdown of the certificate's primary and subkeys, so a list can flag a key
+    # which has expired or been revoked without a per-key status column of its own. Any link
+    # through to the details belongs on the certificate key id above, not on each row
+    if not signing_keys:
+        return None
+    ordered = sorted(signing_keys, key=lambda k: (not k.is_primary, k.created))
+    usable = sum(1 for k in ordered if signing_key_state(k) == "good")
+
+    summary = htm.Block(htm.summary, classes=".small")
+    summary.text(util.plural(len(ordered), "signing key"))
+    # Only worth calling out when some key can't sign - all usable needs no badge
+    if usable < len(ordered):
+        summary.span(".badge.bg-warning.text-dark.ms-2")[f"{usable} usable"]
+
+    lines = htm.Block(htm.div, classes=".mt-1")
+    for signing_key in ordered:
+        lines.append(
+            htm.div(".small.d-flex.flex-wrap.gap-2.align-items-center.mb-1")[
+                htm.span(".fw-semibold")["Primary" if signing_key.is_primary else "Subkey"],
+                htm.span(".text-muted.font-monospace")[signing_key.key_id.upper()],
+                htm.span(".text-muted")[f"created {signing_key.created.strftime('%Y-%m-%d')}"],
+                htm.span(".text-muted")["expires ", signing_key_expiry(signing_key)],
+                signing_key_status(signing_key),
+            ]
+        )
+    return htm.details(".mt-1")[summary.collect(), lines.collect()]
+
+
 def _get_results_table_css() -> htm.Element:
     return htm.style[
         markupsafe.Markup(
diff --git a/atr/templates/committee-view.html b/atr/templates/committee-view.html
index 82cd2f3e..9130cb69 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -200,8 +200,16 @@
               <tbody>
                 {% for key in signing_keys %}
                   <tr>
-                    <td class="text-break font-monospace px-2">
-                      <a href="{{ as_url(get.keys.details, fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+                    <td class="text-break px-2">
+                      <div class="font-monospace">
+                        {% if key.fingerprint in revoked_certificates %}
+                          <span class="text-decoration-line-through"><a href="{{ as_url(get.keys.details, fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a></span>
+                          <span class="text-muted">(revoked)</span>
+                        {% else %}
+                          <a href="{{ as_url(get.keys.details, fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+                        {% endif %}
+                      </div>
+                      {{ key_lists.get(key.fingerprint) or "" }}
                     </td>
                     <td class="text-break px-2">{{ email_from_key(key.primary_declared_uid or '') or 'Not specified' }}</td>
                     <td class="text-break px-2">{{ key.apache_uid or "−" }}</td>
diff --git a/atr/templates/report-selected-path.html b/atr/templates/report-selected-path.html
index 38c4bb42..dbe858f2 100644
--- a/atr/templates/report-selected-path.html
+++ b/atr/templates/report-selected-path.html
@@ -157,6 +157,17 @@
                             </span>
                           {% endif %}
                         </div>
+                      {% elif primary_result.checker.endswith("signature.check") and primary_result.data.get("fingerprint") %}
+                        {% set signing_fingerprint = primary_result.data.get("fingerprint") %}
+                        {% set certificate_fingerprint = primary_result.data.get("certificate_fingerprint") %}
+                        <div class="mb-2">
+                          {% if certificate_fingerprint and (signing_fingerprint != certificate_fingerprint) %}
+                            <p class="mb-1">Signed by <strong>subkey</strong> <code class="text-break">{{ signing_fingerprint|upper }}</code></p>
+                            <p class="mb-0 small text-muted">of certificate <code class="text-break">{{ certificate_fingerprint|upper }}</code></p>
+                          {% else %}
+                            <p class="mb-0">Signed by <strong>primary key</strong> <code class="text-break">{{ signing_fingerprint|upper }}</code></p>
+                          {% endif %}
+                        </div>
                       {% endif %}
                       <table class="table table-bordered table-sm mb-0">
                         <tbody>


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