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