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-atr-experiments.git
The following commit(s) were added to refs/heads/main by this push: new f8fae6a Add pages for user key management f8fae6a is described below commit f8fae6a21db1d37f27c1594c8f8f4345e285f6cb Author: Sean B. Palmer <s...@miscoranda.com> AuthorDate: Fri Feb 14 16:08:56 2025 +0200 Add pages for user key management --- .gitignore | 1 + atr/routes.py | 155 ++++++++++++++++++++++++++- atr/templates/pages.html | 33 ++++++ atr/templates/user-keys-add.html | 222 +++++++++++++++++++++++++++++++++++++++ poetry.lock | 14 ++- pyproject.toml | 2 + uv.lock | 11 ++ 7 files changed, 435 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6b9b7c9..ca93b00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc +.DS_Store .env .ruff_cache/ .venv-poetry/ diff --git a/atr/routes.py b/atr/routes.py index 56ba7b8..f65a69d 100644 --- a/atr/routes.py +++ b/atr/routes.py @@ -19,17 +19,21 @@ import hashlib import json +import pprint from pathlib import Path -from typing import List, Tuple +from typing import List, Tuple, Optional +import datetime +import asyncio from asfquart import APP from asfquart.auth import Requirements as R, require from asfquart.base import ASFQuartException -from asfquart.session import read as session_read +from asfquart.session import read as session_read, ClientSession from quart import current_app, render_template, request from sqlmodel import Session, select from sqlalchemy.exc import IntegrityError import httpx +import gnupg from .models import ( DistributionChannel, @@ -404,6 +408,65 @@ async def root_secret() -> str: return "Secret stuff!" +@APP.route("/user/keys/add", methods=["GET", "POST"]) +@require(R.committer) +async def root_user_keys_add() -> str: + "Add a new GPG key to the user's account." + session = await session_read() + if session is None: + raise ASFQuartException("Not authenticated", errorcode=401) + + error = None + key_info = None + user_keys = [] + + # Get all existing keys for the user + with Session(current_app.config["engine"]) as db_session: + statement = select(PublicSigningKey).where(PublicSigningKey.user_id == session.uid) + user_keys = db_session.exec(statement).all() + + if request.method == "POST": + form = await request.form + public_key = form.get("public_key") + if not public_key: + # Shouldn't happen, so we can raise an exception + raise ASFQuartException("Public key is required", errorcode=400) + error, key_info = await user_keys_add(session, public_key) + + return await render_template( + "user-keys-add.html", + asf_id=session.uid, + pmc_memberships=session.committees, + error=error, + key_info=key_info, + user_keys=user_keys, + ) + + +@APP.route("/user/keys/delete") +@require(R.committer) +async def root_user_keys_delete() -> str: + "Debug endpoint to delete all of a user's keys." + session = await session_read() + if session is None: + raise ASFQuartException("Not authenticated", errorcode=401) + + with Session(current_app.config["engine"]) as db_session: + # Get all keys for the user + # TODO: Might be clearer if user_id were "asf_id" + # But then we'd also want session.uid to be session.asf_id instead + statement = select(PublicSigningKey).where(PublicSigningKey.user_id == session.uid) + keys = db_session.exec(statement).all() + count = len(keys) + + # Delete all keys + for key in keys: + db_session.delete(key) + db_session.commit() + + return f"Deleted {count} keys" + + @APP.route("/user/uploads") @require(R.committer) async def root_user_uploads() -> str: @@ -449,3 +512,91 @@ async def save_file_by_hash(file, base_dir: Path) -> Tuple[Path, str]: path.write_bytes(data) return path, file_hash + + +async def user_keys_add(session: ClientSession, public_key: str) -> Tuple[str, Optional[dict]]: + if not public_key: + return ("Public key is required", None) + + # 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 + gpg = gnupg.GPG() + import_result = await asyncio.to_thread(gpg.import_keys, public_key) + + if not import_result.fingerprints: + return ("Invalid public key format", None) + + fingerprint = import_result.fingerprints[0] + # Get key details + # We could probably use import_result instead + # But this way it shows that they've really been imported + 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"] == fingerprint), None) + if not key: + return ("Failed to import key", None) + 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 + return ("Key is not long enough; must be at least 2048 bits", None) + + # Store key in database + with Session(current_app.config["engine"]) as db_session: + return await user_keys_add_session(session, public_key, key, db_session) + + +async def user_keys_add_session( + session: ClientSession, public_key: str, key: dict, db_session: Session +) -> Tuple[str, Optional[dict]]: + # Check if key already exists + statement = select(PublicSigningKey).where(PublicSigningKey.user_id == session.uid) + existing_key = db_session.exec(statement).first() + + if existing_key: + # TODO: We should allow more than one key per user + return ("You already have a key registered", None) + + if not session.uid: + return ("You must be signed in to add a key", None) + + # Create new key record + key_record = PublicSigningKey( + user_id=session.uid, + public_key=public_key, + key_type=key.get("type", "unknown"), + expiration=datetime.datetime.fromtimestamp(int(key["expires"])) + if key.get("expires") + else datetime.datetime.max, + ) + db_session.add(key_record) + + # Link key to user's PMCs + for pmc_name in session.committees: + statement = select(PMC).where(PMC.project_name == pmc_name) + pmc = db_session.exec(statement).first() + if pmc and pmc.id and session.uid: + link = PMCKeyLink(pmc_id=pmc.id, key_user_id=session.uid) + db_session.add(link) + else: + # TODO: Log? Add to "error"? + continue + + try: + db_session.commit() + except IntegrityError: + db_session.rollback() + return ("Failed to save key", None) + + return ( + "", + { + "key_id": key["keyid"], + "fingerprint": key["fingerprint"], + "user_id": key["uids"][0] if key.get("uids") else "Unknown", + "creation_date": datetime.datetime.fromtimestamp(int(key["date"])), + "expiration_date": datetime.datetime.fromtimestamp(int(key["expires"])) if key.get("expires") else None, + "data": pprint.pformat(key), + }, + ) diff --git a/atr/templates/pages.html b/atr/templates/pages.html index 1d56336..b018e11 100644 --- a/atr/templates/pages.html +++ b/atr/templates/pages.html @@ -58,6 +58,13 @@ background: #e6ffe6; border: 1px solid #ccebcc; } + + .access-requirement.warning { + background: #ffe6e6; + border: 1px solid #ffcccc; + color: #cc0000; + font-weight: bold; + } </style> </head> <body> @@ -155,6 +162,32 @@ </div> </div> + <div class="endpoint-group"> + <h2>User Management</h2> + + <div class="endpoint"> + <h3> + <a href="{{ url_for('root_user_keys_add') }}">/user/keys/add</a> + </h3> + <div class="endpoint-description">Add a GPG public key to your account for signing releases.</div> + <div class="endpoint-meta"> + Access: <span class="access-requirement committer">Committer</span> + </div> + </div> + + <div class="endpoint"> + <h3> + <a href="{{ url_for('root_user_keys_delete') }}">/user/keys/delete</a> + </h3> + <div class="endpoint-description">Debug endpoint to delete all GPG keys associated with your account.</div> + <div class="endpoint-meta"> + Access: <span class="access-requirement committer">Committer</span> + <br /> + <span class="access-requirement warning">Warning: This is a debug endpoint that immediately deletes all your keys without confirmation!</span> + </div> + </div> + </div> + <div class="endpoint-group"> <h2>Administration</h2> diff --git a/atr/templates/user-keys-add.html b/atr/templates/user-keys-add.html new file mode 100644 index 0000000..af8ce40 --- /dev/null +++ b/atr/templates/user-keys-add.html @@ -0,0 +1,222 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1.0" /> + <meta name="description" content="Add a GPG public key to your account." /> + <title>ATR | Add GPG Key</title> + <link rel="stylesheet" href="{{ url_for('static', filename='root.css') }}" /> + <style> + .form-group { + margin-bottom: 1rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + } + + .error-message { + color: #dc3545; + margin-top: 0.25rem; + } + + input, + textarea { + font-family: monospace; + padding: 0.5rem; + } + + textarea { + width: 100%; + min-height: 200px; + } + + .key-info { + margin-top: 1rem; + padding: 1rem; + background: #f8f9fa; + border-radius: 4px; + } + + .key-info h3 { + margin-top: 0; + } + + .key-info dl { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1rem; + } + + .key-info dt { + font-weight: bold; + } + + .key-info dd { + margin: 0; + } + + button { + margin-top: 1rem; + padding: 0.5rem 1rem; + } + + .navigation { + margin-top: 2rem; + } + + .success-message { + color: #28a745; + margin: 1rem 0; + padding: 1rem; + background: #d4edda; + border-radius: 4px; + } + + .existing-keys { + margin: 2rem 0; + padding: 1rem; + background: #f8f9fa; + border-radius: 4px; + } + + .keys-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin-top: 1rem; + } + + .key-card { + padding: 1rem; + background: white; + border: 1px solid #dee2e6; + border-radius: 4px; + } + + .key-card h3 { + margin-top: 0; + margin-bottom: 1rem; + } + + .delete-key-form { + margin-top: 1rem; + } + + .delete-button { + background: #dc3545; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + } + + .delete-button:hover { + background: #c82333; + } + </style> + </head> + <body> + <h1>Add GPG Public Key</h1> + <p class="intro">Add your GPG public key to use for signing release artifacts.</p> + + <div class="user-info"> + <p> + Welcome, <strong>{{ asf_id }}</strong>! You are authenticated as an ASF committer. + </p> + </div> + + {% if key_info %} + <div class="key-info"> + <h3>Success: Added Key</h3> + <dl> + <dt>Key ID</dt> + <dd> + {{ key_info.key_id }} + </dd> + <dt>Fingerprint</dt> + <dd> + {{ key_info.fingerprint }} + </dd> + <dt>User ID</dt> + <dd> + {{ key_info.user_id }} + </dd> + <dt>Created</dt> + <dd> + {{ key_info.creation_date }} + </dd> + <dt>Expires</dt> + <dd> + {{ key_info.expiration_date or 'Never' }} + </dd> + <dt>Key Data</dt> + <dd> + <pre>{{ key_info.data }}</pre> + </dd> + </dl> + </div> + {% endif %} + + {% if error %} + <div class="error-message"> + <h2>Error: Did Not Add Key</h2> + <p>{{ error }}</p> + </div> + {% endif %} + + {% if success %} + <div class="success-message"> + <h2>Success</h2> + <p>{{ success }}</p> + </div> + {% endif %} + + {% if user_keys %} + <div class="existing-keys"> + <h2>Your Existing Keys</h2> + <div class="keys-grid"> + {% for key in user_keys %} + <div class="key-card"> + <h3>Key Details</h3> + <dl> + <dt>Key Type</dt> + <dd> + {{ key.key_type }} + </dd> + <dt>Expires</dt> + <dd> + {{ key.expiration.strftime("%Y-%m-%d %H:%M:%S") if key.expiration else 'Never' }} + </dd> + </dl> + </div> + {% endfor %} + </div> + </div> + {% endif %} + + <form method="post"> + <div class="form-group"> + <label for="public_key">Public Key:</label> + <textarea id="public_key" + name="public_key" + required + placeholder="Paste your GPG public key here (in ASCII-armored format)" + aria-describedby="key-help"></textarea> + <small id="key-help"> + Your public key should be in ASCII-armored format, starting with "-----BEGIN PGP PUBLIC KEY BLOCK-----" + </small> + </div> + + <button type="submit">Add Key</button> + </form> + + <div class="navigation"> + <a href="{{ url_for('root_user_uploads') }}">Back to Your Uploads</a> + <a href="{{ url_for('root_pages') }}">Return to Main Page</a> + </div> + </body> +</html> diff --git a/poetry.lock b/poetry.lock index 97a223e..b541eb2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1912,6 +1912,18 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-gnupg" +version = "0.5.4" +description = "A wrapper for the Gnu Privacy Guard (GPG or GnuPG)" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python-gnupg-0.5.4.tar.gz", hash = "sha256:f2fdb5fb29615c77c2743e1cb3d9314353a6e87b10c37d238d91ae1c6feae086"}, + {file = "python_gnupg-0.5.4-py2.py3-none-any.whl", hash = "sha256:40ce25cde9df29af91fe931ce9df3ce544e14a37f62b13ca878c897217b2de6c"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -2593,4 +2605,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "~=3.13" -content-hash = "211ad6b84f4ca3f7e8f96e8d70440fde5bffca23d51f015b205d2c037337b764" +content-hash = "ef8075690ef0c336342f273462d074b3c73b8eea01e608cdfbbee93787178303" diff --git a/pyproject.toml b/pyproject.toml index f8ca0ee..2766522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "cryptography~=44.0", "httpx~=0.27", "hypercorn~=0.17", + "python-gnupg~=0.5", "sqlmodel~=0.0", ] @@ -44,6 +45,7 @@ asfquart = { path = "./asfquart", develop = true } cryptography = "~=44.0" httpx = "~=0.27" hypercorn = "~=0.17" +python-gnupg = "~=0.5" sqlmodel = "~=0.0" [tool.pyright] diff --git a/uv.lock b/uv.lock index 6bd7588..2417b89 100644 --- a/uv.lock +++ b/uv.lock @@ -921,6 +921,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, ] +[[package]] +name = "python-gnupg" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/3e/ba0dc69c9f4e0aeb24d93175230ef057c151790a7516012f61014918992d/python-gnupg-0.5.4.tar.gz", hash = "sha256:f2fdb5fb29615c77c2743e1cb3d9314353a6e87b10c37d238d91ae1c6feae086", size = 65705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/5b/6666ed5a0d3ce4d5444af62e373d5ba8ab253a03487c86f2f9f1078e7c31/python_gnupg-0.5.4-py2.py3-none-any.whl", hash = "sha256:40ce25cde9df29af91fe931ce9df3ce544e14a37f62b13ca878c897217b2de6c", size = 21730 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1083,6 +1092,7 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "hypercorn" }, + { name = "python-gnupg" }, { name = "sqlmodel" }, ] @@ -1102,6 +1112,7 @@ requires-dist = [ { name = "cryptography", specifier = "~=44.0" }, { name = "httpx", specifier = "~=0.27" }, { name = "hypercorn", specifier = "~=0.17" }, + { name = "python-gnupg", specifier = "~=0.5" }, { name = "sqlmodel", specifier = "~=0.0" }, ] --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tooling.apache.org For additional commands, e-mail: dev-h...@tooling.apache.org