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).
+
{{ error }}
+ {% endif %} + +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 @@
+ 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).
+
{{ error }}
+ {% endif %} + +