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

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new ab6a518  Convert signature checks to check result style
ab6a518 is described below

commit ab6a518ba4839992c040f5e90398fb72f9219004
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Mar 31 16:44:51 2025 +0100

    Convert signature checks to check result style
---
 atr/routes/keys.py                   |  73 ++++++++++++++++---------
 atr/tasks/__init__.py                |  30 ++++++-----
 atr/tasks/{ => checks}/signature.py  | 101 ++++++++++++++++++++++-------------
 atr/templates/draft-review-path.html |  21 ++++++++
 atr/worker.py                        |   4 +-
 5 files changed, 151 insertions(+), 78 deletions(-)

diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 6f0fea1..127b163 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -113,38 +113,56 @@ def key_ssh_fingerprint(ssh_key_string: str) -> str:
     raise ValueError("Invalid SSH key format")
 
 
-async def key_user_add(asf_uid: str | None, public_key: str, 
selected_committees: list[str]) -> dict | None:
-    if not public_key:
-        raise routes.FlashError("Public key is required")
-
-    # Import the key into GPG to validate and extract info
-    # TODO: We'll just assume for now that gnupg.GPG() doesn't need to be async
+async def _key_user_add_validate_key_properties(public_key: str) -> 
tuple[dict, str]:
+    """Validate GPG key string, import it, and return its properties and 
fingerprint."""
     async with ephemeral_gpg_home() as gpg_home:
         gpg = gnupg.GPG(gnupghome=gpg_home)
         import_result = await asyncio.to_thread(gpg.import_keys, public_key)
 
         if not import_result.fingerprints:
-            raise routes.FlashError("Invalid public key format")
+            raise routes.FlashError("Invalid public key format or failed 
import")
 
         fingerprint = import_result.fingerprints[0]
-        if fingerprint is not None:
-            fingerprint = fingerprint.lower()
-        # APP.logger.info("Import result: %s", vars(import_result))
-        # Get key details
-        # We could probably use import_result instead
-        # But this way it shows that they've really been imported
+        if fingerprint is None:
+            # Should be unreachable given the previous check, but satisfy type 
checker
+            raise routes.FlashError("Failed to get fingerprint after import")
+        fingerprint_lower = fingerprint.lower()
+
+        # List keys to get details
         keys = await asyncio.to_thread(gpg.list_keys)
 
-    # Then we have the properties listed here:
-    # https://gnupg.readthedocs.io/en/latest/#listing-keys
-    # Note that "fingerprint" is not listed there, but we have it anyway...
-    key = next((k for k in keys if (k["fingerprint"] is not None) and 
(k["fingerprint"].lower() == fingerprint)), None)
-    if not key:
-        raise routes.FlashError("Failed to import key")
-    if (key.get("algo") == "1") and (int(key.get("length", "0")) < 2048):
-        # https://infra.apache.org/release-signing.html#note
-        # Says that keys must be at least 2048 bits
-        raise routes.FlashError("Key is not long enough; must be at least 2048 
bits")
+    # Find the specific key details from the list using the fingerprint
+    key_details = None
+    for k in keys:
+        if k.get("fingerprint") is not None and k["fingerprint"].lower() == 
fingerprint_lower:
+            key_details = k
+            break
+
+    if not key_details:
+        # This might indicate an issue with gpg.list_keys or the environment
+        logging.error(
+            f"Could not find key details for fingerprint {fingerprint_lower}"
+            f" after successful import. Keys listed: {keys}"
+        )
+        raise routes.FlashError("Failed to retrieve key details after import")
+
+    # Validate key algorithm and length
+    # https://infra.apache.org/release-signing.html#note
+    # Says that keys must be at least 2048 bits
+    if (key_details.get("algo") == "1") and (int(key_details.get("length", 
"0")) < 2048):
+        raise routes.FlashError("RSA Key is not long enough; must be at least 
2048 bits")
+
+    return key_details, fingerprint_lower
+
+
+async def key_user_add(asf_uid: str | None, public_key: str, 
selected_committees: list[str]) -> dict | None:
+    if not public_key:
+        raise routes.FlashError("Public key is required")
+
+    # Validate the key using GPG and get its properties
+    key, _fingerprint = await _key_user_add_validate_key_properties(public_key)
+
+    # Determine ASF UID if not provided
     if asf_uid is None:
         for uid in key["uids"]:
             match = re.search(r"([A-Za-z0-9]+)@apache.org", uid)
@@ -152,7 +170,7 @@ async def key_user_add(asf_uid: str | None, public_key: 
str, selected_committees
                 asf_uid = match.group(1).lower()
                 break
         else:
-            logging.warning(f"key_user_add called with no ASF UID: {keys}")
+            logging.warning(f"key_user_add called with no ASF UID found in key 
UIDs: {key.get('uids')}")
     if asf_uid is None:
         # We place this here to make it easier on the type checkers
         raise routes.FlashError("No Apache UID found in the key UIDs")
@@ -178,6 +196,13 @@ async def key_user_session_add(
     #     return ("You already have a key registered", None)
 
     fingerprint = key.get("fingerprint")
+    # for subkey in key.get("subkeys", []):
+    #     if subkey[1] == "s":
+    #         # It's a signing key, so use its fingerprint instead
+    #         # TODO: Not sure that we should do this
+    #         # TODO: Check for multiple signing subkeys
+    #         fingerprint = subkey[2]
+    #         break
     if not isinstance(fingerprint, str):
         raise routes.FlashError("Invalid key fingerprint")
     fingerprint = fingerprint.lower()
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 6cb5d78..45eb28d 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -23,6 +23,7 @@ import atr.tasks.checks.archive as archive
 import atr.tasks.checks.hashing as hashing
 import atr.tasks.checks.license as license
 import atr.tasks.checks.rat as rat
+import atr.tasks.checks.signature as signature
 import atr.util as util
 
 
@@ -42,12 +43,13 @@ async def asc_checks(release: models.Release, 
signature_path: str) -> list[model
         tasks.append(
             models.Task(
                 status=models.TaskStatus.QUEUED,
-                task_type="verify_signature",
-                task_args=[
-                    release.committee.name,
-                    full_artifact_path,
-                    full_signature_path,
-                ],
+                task_type=checks.function_key(signature.check),
+                task_args=signature.Check(
+                    release_name=release.name,
+                    committee_name=release.committee.name,
+                    abs_artifact_path=full_artifact_path,
+                    abs_signature_path=full_signature_path,
+                ).model_dump(),
                 release_name=release.name,
                 path=signature_path,
                 modified=modified,
@@ -139,14 +141,14 @@ async def tar_gz_checks(release: models.Release, path: 
str) -> list[models.Task]
             path=path,
             modified=modified,
         ),
-        models.Task(
-            status=models.TaskStatus.QUEUED,
-            task_type="generate_cyclonedx_sbom",
-            task_args=[full_path],
-            release_name=release.name,
-            path=path,
-            modified=modified,
-        ),
+        # models.Task(
+        #     status=models.TaskStatus.QUEUED,
+        #     task_type="generate_cyclonedx_sbom",
+        #     task_args=[full_path],
+        #     release_name=release.name,
+        #     path=path,
+        #     modified=modified,
+        # ),
     ]
 
     return tasks
diff --git a/atr/tasks/signature.py b/atr/tasks/checks/signature.py
similarity index 51%
rename from atr/tasks/signature.py
rename to atr/tasks/checks/signature.py
index f80406d..7b9595b 100644
--- a/atr/tasks/signature.py
+++ b/atr/tasks/checks/signature.py
@@ -15,68 +15,89 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import contextlib
+import asyncio
 import logging
-import shutil
 import tempfile
-from collections.abc import Generator
-from typing import Any, BinaryIO, Final
+from typing import Any, Final
 
 import gnupg
-import sqlalchemy.sql as sql
+import pydantic
+import sqlmodel
 
 import atr.db as db
 import atr.db.models as models
-import atr.tasks.task as task
+import atr.tasks.checks as checks
 
 _LOGGER = logging.getLogger(__name__)
 
 
-def check(args: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any, 
...]]:
+class Check(pydantic.BaseModel):
+    """Parameters for signature checking."""
+
+    release_name: str = pydantic.Field(..., description="Release name")
+    committee_name: str = pydantic.Field(..., description="Name of the 
committee whose keys should be used")
+    abs_artifact_path: str = pydantic.Field(..., description="Absolute path to 
the artifact file")
+    abs_signature_path: str = pydantic.Field(..., description="Absolute path 
to the signature file (.asc)")
+
+
[email protected]_model(Check)
+async def check(args: Check) -> str | None:
     """Check a signature file."""
-    task_results = task.results_as_tuple(_check_core(*args))
-    _LOGGER.info(f"Verified {args} with result {task_results[0]}")
-    status = task.FAILED if task_results[0].get("error") else task.COMPLETED
-    error = task_results[0].get("error")
-    return status, error, task_results
+    rel_path = checks.rel_path(args.abs_signature_path)
+    check_instance = await checks.Check.create(checker=check, 
release_name=args.release_name, path=rel_path)
+    _LOGGER.info(
+        f"Checking signature {args.abs_signature_path} for 
{args.abs_artifact_path}"
+        f" using {args.committee_name} keys (rel: {rel_path})"
+    )
+
+    try:
+        result_data = await _check_core_logic(
+            committee_name=args.committee_name,
+            artifact_path=args.abs_artifact_path,
+            signature_path=args.abs_signature_path,
+        )
+        if result_data.get("error"):
+            await check_instance.failure(result_data["error"], result_data)
+        elif result_data.get("verified"):
+            await check_instance.success("Signature verified successfully", 
result_data)
+        else:
+            # Shouldn't happen
+            await check_instance.exception("Signature verification failed for 
unknown reasons", result_data)
 
+    except Exception as e:
+        await check_instance.exception("Error during signature check 
execution", {"error": str(e)})
 
-def _check_core(committee_name: str, artifact_path: str, signature_path: str) 
-> dict[str, Any]:
+    return None
+
+
+async def _check_core_logic(committee_name: str, artifact_path: str, 
signature_path: str) -> dict[str, Any]:
     """Verify a signature file using the committee's public signing keys."""
-    # Query only the signing keys associated with this committee
-    # TODO: Rename create_sync_db_session to create_session_sync
-    # Using isinstance does not work here, with pyright
+    _LOGGER.info(f"Attempting to fetch keys for committee_name: 
'{committee_name}'")
     name = db.validate_instrumented_attribute(models.Committee.name)
-    with db.create_sync_db_session() as session:
-        # TODO: This is our only remaining use of select
+    async with db.session() as session:
         statement = (
-            sql.select(models.PublicSigningKey)
+            sqlmodel.select(models.PublicSigningKey)
             .join(models.KeyLink)
             .join(models.Committee)
             .where(name == committee_name)
         )
-        result = session.execute(statement)
+        result = await session.execute(statement)
         public_keys = [key.ascii_armored_key for key in result.scalars().all()]
 
-    with open(signature_path, "rb") as sig_file:
-        return _signature_gpg_file(sig_file, artifact_path, public_keys)
-
-
[email protected]
-def _ephemeral_gpg_home() -> Generator[str]:
-    # TODO: Deduplicate, and move somewhere more appropriate
-    """Create a temporary directory for an isolated GPG home, and clean it up 
on exit."""
-    temp_dir = tempfile.mkdtemp(prefix="gpg-")
-    try:
-        yield temp_dir
-    finally:
-        shutil.rmtree(temp_dir)
+    return await asyncio.to_thread(
+        _check_core_logic_verify_signature,
+        signature_path=signature_path,
+        artifact_path=artifact_path,
+        ascii_armored_keys=public_keys,
+    )
 
 
-def _signature_gpg_file(sig_file: BinaryIO, artifact_path: str, 
ascii_armored_keys: list[str]) -> dict[str, Any]:
+def _check_core_logic_verify_signature(
+    signature_path: str, artifact_path: str, ascii_armored_keys: list[str]
+) -> dict[str, Any]:
     """Verify a GPG signature for a file."""
-    with _ephemeral_gpg_home() as gpg_home:
-        gpg: Final[gnupg.GPG] = gnupg.GPG(gnupghome=gpg_home)
+    with tempfile.TemporaryDirectory(prefix="gpg-") as gpg_dir, 
open(signature_path, "rb") as sig_file:
+        gpg: Final[gnupg.GPG] = gnupg.GPG(gnupghome=gpg_dir)
 
         # Import all PMC public signing keys
         for key in ascii_armored_keys:
@@ -103,14 +124,18 @@ def _signature_gpg_file(sig_file: BinaryIO, 
artifact_path: str, ascii_armored_ke
     }
 
     if not verified:
-        raise task.Error("No valid signature found", debug_info)
+        return {
+            "verified": False,
+            "error": "No valid signature found",
+            "debug_info": debug_info,
+        }
 
     return {
         "verified": True,
         "key_id": verified.key_id,
         "timestamp": verified.timestamp,
         "username": verified.username or "Unknown",
-        "email": verified.pubkey_fingerprint.lower() or "Unknown",
+        "fingerprint": verified.pubkey_fingerprint.lower() or "Unknown",
         "status": "Valid signature",
         "debug_info": debug_info,
     }
diff --git a/atr/templates/draft-review-path.html 
b/atr/templates/draft-review-path.html
index 43e7883..967fa37 100644
--- a/atr/templates/draft-review-path.html
+++ b/atr/templates/draft-review-path.html
@@ -176,6 +176,27 @@
                         </tr>
                       {% endif %}
                     {% endfor %}
+
+                    {% if check_result.data.get("debug_info") is mapping %}
+                      {% for debug_key, debug_value in 
check_result.data.debug_info.items() %}
+                        <tr>
+                          <th class="bg-light fw-bold align-top">
+                            <span class="text-muted">(Debug)</span> {{ 
debug_key|replace('_', ' ') |title }}
+                          </th>
+                          <td>
+                            {% if debug_key == 'stderr' %}
+                              <pre class="mb-0 small"><code class="small">{{ 
debug_value }}</code></pre>
+                            {% elif debug_value is boolean %}
+                              {{ "Yes" if debug_value else "No" }}
+                            {% elif debug_value is none %}
+                              <span class="text-muted">(None)</span>
+                            {% else %}
+                              {{ debug_value }}
+                            {% endif %}
+                          </td>
+                        </tr>
+                      {% endfor %}
+                    {% endif %}
                   </tbody>
                 </table>
               </details>
diff --git a/atr/worker.py b/atr/worker.py
index 1e0bd0f..0ee92cf 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -43,10 +43,10 @@ import atr.tasks.checks.archive as archive
 import atr.tasks.checks.hashing as hashing
 import atr.tasks.checks.license as license
 import atr.tasks.checks.rat as rat
+import atr.tasks.checks.signature as signature
 import atr.tasks.mailtest as mailtest
 import atr.tasks.rsync as rsync
 import atr.tasks.sbom as sbom
-import atr.tasks.signature as signature
 import atr.tasks.task as task
 import atr.tasks.vote as vote
 
@@ -193,6 +193,7 @@ async def _task_process(task_id: int, task_type: str, 
task_args: list[str] | dic
             checks.function_key(license.files): license.files,
             checks.function_key(license.headers): license.headers,
             checks.function_key(rat.check): rat.check,
+            checks.function_key(signature.check): signature.check,
         }
         # TODO: We should use a decorator to register these automatically
         dict_task_handlers = {
@@ -202,7 +203,6 @@ async def _task_process(task_id: int, task_type: str, 
task_args: list[str] | dic
         # TODO: These are synchronous
         # We plan to convert these to async dict handlers
         list_task_handlers = {
-            "verify_signature": signature.check,
             "generate_cyclonedx_sbom": sbom.generate_cyclonedx,
             "mailtest_send": mailtest.send,
             "vote_initiate": vote.initiate,


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to