From e30dba417dbbf15be8ca1f69e09034772e25a74e Mon Sep 17 00:00:00 2001 From: Undline <103777919+Undline@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:18:56 -0400 Subject: [PATCH] keymaster: rename identity, export public JSON, name hints Add GET/POST /identities/{id}/rename (vault passphrase + disk re-encrypt) and GET /identities/{id}/export-pub for modulr_keymaster_ed25519_public_v1 JSON download. Shared validate_display_name; ProfileSecrets.to_export_public_v1; datalist suggestions for personal/organization on new and rename forms. README route table; tests for rename/export and ruff-friendly imports in test_session_vault_sync. Made-with: Cursor --- keymaster/README.md | 2 + keymaster/src/modulr_keymaster/app.py | 186 ++++++++++++++++-- keymaster/src/modulr_keymaster/profiles.py | 44 ++++- .../src/modulr_keymaster/templates/base.html | 2 +- .../templates/profile_detail.html | 2 + .../templates/profile_new.html | 7 +- .../templates/profile_rename.html | 50 +++++ keymaster/tests/test_rename_and_export.py | 122 ++++++++++++ keymaster/tests/test_session_vault_sync.py | 6 +- 9 files changed, 391 insertions(+), 30 deletions(-) create mode 100644 keymaster/src/modulr_keymaster/templates/profile_rename.html create mode 100644 keymaster/tests/test_rename_and_export.py diff --git a/keymaster/README.md b/keymaster/README.md index 93feabb..0e403dc 100644 --- a/keymaster/README.md +++ b/keymaster/README.md @@ -50,5 +50,7 @@ pytest | `/identities/new` | GET/POST | Add Ed25519 profile (session + vault passphrase to re-encrypt disk) | | `/identities/{id}` | GET | Profile + public key (requires session) | | `/identities/{id}/sign` | GET/POST | Paste genesis/admin challenge text; sign **UTF-8 bytes** with Ed25519; show signature hex (requires session) | +| `/identities/{id}/rename` | GET/POST | Change display name (vault passphrase to re-encrypt disk; requires session) | +| `/identities/{id}/export-pub` | GET | Download **JSON** public key metadata (`*-ed25519.pub.json`; requires session) | Static assets: `src/modulr_keymaster/static/`; templates: `templates/`. diff --git a/keymaster/src/modulr_keymaster/app.py b/keymaster/src/modulr_keymaster/app.py index 6c46212..4d5cb60 100644 --- a/keymaster/src/modulr_keymaster/app.py +++ b/keymaster/src/modulr_keymaster/app.py @@ -2,23 +2,26 @@ from __future__ import annotations +import json +import re from contextlib import asynccontextmanager from pathlib import Path from typing import Annotated from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from modulr_keymaster.paths import vault_exists, vault_json_path from modulr_keymaster.profiles import ( - DISPLAY_NAME_MAX_LEN, empty_inner_payload, inner_payload_to_profiles, new_profile, profiles_to_inner_payload, + rename_profile_in_list, sign_challenge_utf8, + validate_display_name, ) from modulr_keymaster.sessions import ( SESSION_COOKIE, @@ -281,20 +284,9 @@ async def identities_new_post( if resolve_unlocked_vault(sessions, sid) is None: return RedirectResponse("/unlock", status_code=303) - name = (display_name or "").strip() - if not name: - return templates.TemplateResponse( - request, - "profile_new.html", - _ctx( - request, - page_title="New identity", - nav_section="new", - error="Enter a display name.", - ), - status_code=400, - ) - if len(name) > DISPLAY_NAME_MAX_LEN: + try: + name = validate_display_name(display_name) + except ValueError as e: return templates.TemplateResponse( request, "profile_new.html", @@ -302,10 +294,7 @@ async def identities_new_post( request, page_title="New identity", nav_section="new", - error=( - "Display name must be at most " - f"{DISPLAY_NAME_MAX_LEN} characters." - ), + error=str(e), ), status_code=400, ) @@ -441,6 +430,163 @@ async def identity_sign_post( status_code=400 if err else 200, ) + def _safe_export_pub_filename(display_name: str, profile_id: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9._-]+", "_", (display_name or "").strip()) + slug = slug.strip("._-")[:48] or profile_id.split("-", 1)[0] + return f"{slug}-ed25519.pub.json" + + @app.get("/identities/{profile_id}/rename", response_model=None) + async def identity_rename_get( + request: Request, + profile_id: str, + ) -> HTMLResponse | RedirectResponse: + vault = _session_vault(request) + if vault is None: + return RedirectResponse("/unlock", status_code=302) + profile = find_profile(vault, profile_id) + if profile is None: + return templates.TemplateResponse( + request, + "not_found.html", + _ctx(request, page_title="Not found", nav_section="none"), + status_code=404, + ) + return templates.TemplateResponse( + request, + "profile_rename.html", + _ctx( + request, + page_title=f"Rename — {profile.display_name}", + profile=profile.to_public_dict(), + nav_section="rename", + error=None, + form_display_name=profile.display_name, + ), + ) + + @app.post("/identities/{profile_id}/rename", response_model=None) + async def identity_rename_post( + request: Request, + profile_id: str, + display_name: Annotated[str, Form()], + passphrase: Annotated[str, Form()], + ) -> HTMLResponse | RedirectResponse: + sid = request.cookies.get(SESSION_COOKIE) + sessions = request.app.state.keymaster_sessions + if resolve_unlocked_vault(sessions, sid) is None: + return RedirectResponse("/unlock", status_code=303) + + vault = _session_vault(request) + if vault is None: + return RedirectResponse("/unlock", status_code=303) + if find_profile(vault, profile_id) is None: + return templates.TemplateResponse( + request, + "not_found.html", + _ctx(request, page_title="Not found", nav_section="none"), + status_code=404, + ) + + try: + validate_display_name(display_name) + except ValueError as e: + prof = find_profile(vault, profile_id) + assert prof is not None + return templates.TemplateResponse( + request, + "profile_rename.html", + _ctx( + request, + page_title=f"Rename — {prof.display_name}", + profile=prof.to_public_dict(), + nav_section="rename", + error=str(e), + form_display_name=(display_name or "").strip(), + ), + status_code=400, + ) + + path = vault_json_path() + try: + envelope = read_envelope(path) + disk_inner = decrypt_vault_payload(passphrase, envelope) + profiles = inner_payload_to_profiles(disk_inner) + except (OSError, ValueError, VaultCryptoError): + prof = find_profile(vault, profile_id) + assert prof is not None + return templates.TemplateResponse( + request, + "profile_rename.html", + _ctx( + request, + page_title=f"Rename — {prof.display_name}", + profile=prof.to_public_dict(), + nav_section="rename", + error="Incorrect passphrase, or the vault file is damaged.", + form_display_name=(display_name or "").strip(), + ), + status_code=401, + ) + + if not rename_profile_in_list(profiles, profile_id, display_name): + return templates.TemplateResponse( + request, + "not_found.html", + _ctx(request, page_title="Not found", nav_section="none"), + status_code=404, + ) + + inner_out = profiles_to_inner_payload(profiles) + try: + envelope_out = encrypt_vault_payload(passphrase, inner_out) + write_envelope(path, envelope_out) + except OSError: + prof = find_profile(vault, profile_id) + assert prof is not None + return templates.TemplateResponse( + request, + "profile_rename.html", + _ctx( + request, + page_title=f"Rename — {prof.display_name}", + profile=prof.to_public_dict(), + nav_section="rename", + error="Could not write the vault file.", + form_display_name=(display_name or "").strip(), + ), + status_code=500, + ) + + replace_session_vault(sessions, sid, UnlockedVault(profiles)) + return RedirectResponse(f"/identities/{profile_id}", status_code=303) + + @app.get("/identities/{profile_id}/export-pub", response_model=None) + async def identity_export_pub( + request: Request, + profile_id: str, + ) -> Response | RedirectResponse: + vault = _session_vault(request) + if vault is None: + return RedirectResponse("/unlock", status_code=302) + profile = find_profile(vault, profile_id) + if profile is None: + return templates.TemplateResponse( + request, + "not_found.html", + _ctx(request, page_title="Not found", nav_section="none"), + status_code=404, + ) + body = profile.to_export_public_v1() + filename = _safe_export_pub_filename(profile.display_name, profile.id) + payload = json.dumps(body, indent=2) + "\n" + return Response( + content=payload.encode("utf-8"), + media_type="application/json; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + }, + ) + @app.get("/identities/{profile_id}", response_model=None) async def identity_detail( request: Request, diff --git a/keymaster/src/modulr_keymaster/profiles.py b/keymaster/src/modulr_keymaster/profiles.py index 83a2bc5..1c0b9ad 100644 --- a/keymaster/src/modulr_keymaster/profiles.py +++ b/keymaster/src/modulr_keymaster/profiles.py @@ -41,6 +41,16 @@ def to_public_dict(self) -> dict[str, str]: "public_key_hex": self.public_key_hex(), } + def to_export_public_v1(self) -> dict[str, str]: + """Stable JSON shape for ``.pub.json`` download (public material only).""" + return { + "format": "modulr_keymaster_ed25519_public_v1", + "profile_id": self.id, + "display_name": self.display_name, + "created_at": self.created_at, + "public_key_hex": self.public_key_hex(), + } + def _seed_b64_to_key(seed_b64: str) -> Ed25519PrivateKey: try: @@ -115,6 +125,32 @@ def empty_inner_payload() -> dict[str, Any]: DISPLAY_NAME_MAX_LEN = 80 +def validate_display_name(display_name: str) -> str: + """Return stripped display name or raise ``ValueError``.""" + name = (display_name or "").strip() + if not name: + raise ValueError("display name is required") + if len(name) > DISPLAY_NAME_MAX_LEN: + raise ValueError( + f"display name must be at most {DISPLAY_NAME_MAX_LEN} characters", + ) + return name + + +def rename_profile_in_list( + profiles: list[ProfileSecrets], + profile_id: str, + new_display_name: str, +) -> bool: + """Set ``display_name`` for the matching profile. Returns False if id not found.""" + name = validate_display_name(new_display_name) + for p in profiles: + if p.id == profile_id: + p.display_name = name + return True + return False + + def sign_challenge_utf8(private_key: Ed25519PrivateKey, text: str) -> bytes: """Sign UTF-8 bytes of ``text`` (genesis / admin challenges, interim rule).""" raw = text.encode("utf-8") @@ -127,13 +163,7 @@ def sign_challenge_utf8(private_key: Ed25519PrivateKey, text: str) -> bytes: def new_profile(display_name: str) -> ProfileSecrets: """Generate a new random Ed25519 identity.""" - name = (display_name or "").strip() - if not name: - raise ValueError("display name is required") - if len(name) > DISPLAY_NAME_MAX_LEN: - raise ValueError( - f"display name must be at most {DISPLAY_NAME_MAX_LEN} characters", - ) + name = validate_display_name(display_name) key = Ed25519PrivateKey.generate() now = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") return ProfileSecrets( diff --git a/keymaster/src/modulr_keymaster/templates/base.html b/keymaster/src/modulr_keymaster/templates/base.html index dddbc36..4a835fc 100644 --- a/keymaster/src/modulr_keymaster/templates/base.html +++ b/keymaster/src/modulr_keymaster/templates/base.html @@ -58,7 +58,7 @@ > Identities diff --git a/keymaster/src/modulr_keymaster/templates/profile_detail.html b/keymaster/src/modulr_keymaster/templates/profile_detail.html index 3d6a8fc..d6b4966 100644 --- a/keymaster/src/modulr_keymaster/templates/profile_detail.html +++ b/keymaster/src/modulr_keymaster/templates/profile_detail.html @@ -15,6 +15,8 @@

{{ profile.display_name }}

+ Download .pub.json + Rename Sign challenge All identities diff --git a/keymaster/src/modulr_keymaster/templates/profile_new.html b/keymaster/src/modulr_keymaster/templates/profile_new.html index 81dcc85..36456f2 100644 --- a/keymaster/src/modulr_keymaster/templates/profile_new.html +++ b/keymaster/src/modulr_keymaster/templates/profile_new.html @@ -22,8 +22,13 @@

New identity

autocomplete="off" maxlength="80" required - placeholder="e.g. personal" + list="km-suggested-names" + placeholder="personal, organization, or your own label" /> + + + +
diff --git a/keymaster/src/modulr_keymaster/templates/profile_rename.html b/keymaster/src/modulr_keymaster/templates/profile_rename.html new file mode 100644 index 0000000..cc7434e --- /dev/null +++ b/keymaster/src/modulr_keymaster/templates/profile_rename.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block content %} +
+

Rename identity

+

+ Updates the display name for {{ profile.display_name }}. Enter your + vault passphrase so vault.json can be + re-encrypted on disk (same flow as adding an identity). +

+ {% if error %} + + {% endif %} +
+
+ + + + + + +
+
+ + +
+
+ + Cancel +
+
+
+{% endblock %} diff --git a/keymaster/tests/test_rename_and_export.py b/keymaster/tests/test_rename_and_export.py new file mode 100644 index 0000000..b796ddf --- /dev/null +++ b/keymaster/tests/test_rename_and_export.py @@ -0,0 +1,122 @@ +"""Rename identity and public key JSON export.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from modulr_keymaster.app import create_app +from modulr_keymaster.profiles import inner_payload_to_profiles, rename_profile_in_list +from modulr_keymaster.vault_crypto import decrypt_vault_payload +from modulr_keymaster.vault_file import read_envelope + +PASS = "twelve-chars!" + + +def test_rename_profile_in_list() -> None: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from modulr_keymaster.profiles import ProfileSecrets, validate_display_name + + k = Ed25519PrivateKey.generate() + p = ProfileSecrets( + id="id-1", + display_name="Old", + created_at="2026-01-01T00:00:00Z", + private_key=k, + ) + profiles = [p] + assert rename_profile_in_list(profiles, "id-1", " New Name ") is True + assert profiles[0].display_name == validate_display_name(" New Name ") + assert rename_profile_in_list(profiles, "missing", "x") is False + + +def test_validate_display_name_errors() -> None: + from modulr_keymaster.profiles import DISPLAY_NAME_MAX_LEN, validate_display_name + + with pytest.raises(ValueError, match="required"): + validate_display_name(" ") + with pytest.raises(ValueError, match="at most"): + validate_display_name("x" * (DISPLAY_NAME_MAX_LEN + 1)) + + +def test_rename_http_updates_disk(tmp_path: Path, monkeypatch) -> None: + vault = tmp_path / "vault.json" + monkeypatch.setenv("KEYMASTER_VAULT_PATH", str(vault)) + + with TestClient(create_app()) as client: + assert client.post( + "/setup", + data={"pw1": PASS, "pw2": PASS}, + follow_redirects=False, + ).status_code == 303 + r_new = client.post( + "/identities/new", + data={"display_name": "Alpha", "passphrase": PASS}, + follow_redirects=False, + ) + assert r_new.status_code == 303 + pid = (r_new.headers.get("location") or "").rsplit("/", 1)[-1] + + r_bad = client.post( + f"/identities/{pid}/rename", + data={"display_name": " ", "passphrase": PASS}, + ) + assert r_bad.status_code == 400 + + r_wrong = client.post( + f"/identities/{pid}/rename", + data={"display_name": "Beta", "passphrase": "nope-not-the-pass"}, + ) + assert r_wrong.status_code == 401 + + r_ok = client.post( + f"/identities/{pid}/rename", + data={"display_name": "Beta", "passphrase": PASS}, + follow_redirects=False, + ) + assert r_ok.status_code == 303 + assert r_ok.headers.get("location") == f"/identities/{pid}" + + dash = client.get(f"/identities/{pid}") + assert dash.status_code == 200 + assert b"Beta" in dash.content + assert b"Alpha" not in dash.content + + env = read_envelope(vault) + inner = decrypt_vault_payload(PASS, env) + profs = inner_payload_to_profiles(inner) + assert len(profs) == 1 + assert profs[0].display_name == "Beta" + + +def test_export_pub_json(tmp_path: Path, monkeypatch) -> None: + vault = tmp_path / "vault.json" + monkeypatch.setenv("KEYMASTER_VAULT_PATH", str(vault)) + + with TestClient(create_app()) as client: + client.post( + "/setup", + data={"pw1": PASS, "pw2": PASS}, + follow_redirects=False, + ) + r_new = client.post( + "/identities/new", + data={"display_name": "Gamma Ray", "passphrase": PASS}, + follow_redirects=False, + ) + assert r_new.status_code == 303 + pid = (r_new.headers.get("location") or "").rsplit("/", 1)[-1] + + r = client.get(f"/identities/{pid}/export-pub") + assert r.status_code == 200 + assert "attachment" in r.headers.get("content-disposition", "").lower() + assert "pub.json" in r.headers.get("content-disposition", "") + data = json.loads(r.text) + assert data["format"] == "modulr_keymaster_ed25519_public_v1" + assert data["display_name"] == "Gamma Ray" + assert data["profile_id"] == pid + assert len(data["public_key_hex"]) == 64 diff --git a/keymaster/tests/test_session_vault_sync.py b/keymaster/tests/test_session_vault_sync.py index 8dae04e..ecc1d29 100644 --- a/keymaster/tests/test_session_vault_sync.py +++ b/keymaster/tests/test_session_vault_sync.py @@ -3,7 +3,11 @@ from __future__ import annotations from modulr_keymaster.profiles import new_profile -from modulr_keymaster.sessions import SessionRecord, UnlockedVault, replace_session_vault +from modulr_keymaster.sessions import ( + SessionRecord, + UnlockedVault, + replace_session_vault, +) def test_replace_session_vault_updates_every_active_session() -> None: