Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions keymaster/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
186 changes: 166 additions & 20 deletions keymaster/src/modulr_keymaster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -281,31 +284,17 @@ 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",
_ctx(
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,
)
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 37 additions & 7 deletions keymaster/src/modulr_keymaster/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion keymaster/src/modulr_keymaster/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
>
<a
href="/identities"
class="{% if nav_section == 'identities' or nav_section == 'detail' or nav_section == 'new' or nav_section == 'sign' %}km-nav--active{% endif %}"
class="{% if nav_section == 'identities' or nav_section == 'detail' or nav_section == 'new' or nav_section == 'sign' or nav_section == 'rename' %}km-nav--active{% endif %}"
{% if nav_section == 'identities' %}aria-current="page"{% endif %}
>Identities</a
>
Expand Down
2 changes: 2 additions & 0 deletions keymaster/src/modulr_keymaster/templates/profile_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ <h1 class="km-display km-text">{{ profile.display_name }}</h1>
<button type="button" class="km-btn km-btn--primary" onclick="kmCopyPubkey('km-pubkey-hex')">
Copy public key
</button>
<a class="km-btn km-btn--ghost" href="/identities/{{ profile.id }}/export-pub">Download .pub.json</a>
<a class="km-btn km-btn--ghost" href="/identities/{{ profile.id }}/rename">Rename</a>
<a class="km-btn km-btn--ghost" href="/identities/{{ profile.id }}/sign">Sign challenge</a>
<a class="km-btn km-btn--ghost" href="/identities">All identities</a>
</div>
Expand Down
7 changes: 6 additions & 1 deletion keymaster/src/modulr_keymaster/templates/profile_new.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ <h1 class="km-display km-text">New identity</h1>
autocomplete="off"
maxlength="80"
required
placeholder="e.g. personal"
list="km-suggested-names"
placeholder="personal, organization, or your own label"
/>
<datalist id="km-suggested-names">
<option value="personal"></option>
<option value="organization"></option>
</datalist>
</div>
<div class="km-form-group">
<label class="km-text" for="passphrase">Vault passphrase (same as unlock)</label>
Expand Down
50 changes: 50 additions & 0 deletions keymaster/src/modulr_keymaster/templates/profile_rename.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block content %}
<article class="modulr-glass-surface km-panel">
<h1 class="km-display km-text">Rename identity</h1>
<p class="km-lead km-text-muted">
Updates the display name for <strong class="km-text">{{ profile.display_name }}</strong>. Enter your
<strong class="km-text">vault passphrase</strong> so <code class="km-text">vault.json</code> can be
re-encrypted on disk (same flow as adding an identity).
</p>
{% if error %}
<p class="km-form-error" role="alert">{{ error }}</p>
{% endif %}
<form method="post" action="/identities/{{ profile.id }}/rename">
<div class="km-form-group">
<label class="km-text" for="display_name">Display name</label>
<input
class="km-input"
id="display_name"
name="display_name"
type="text"
autocomplete="off"
maxlength="80"
required
value="{{ form_display_name }}"
list="km-suggested-names"
placeholder="e.g. personal or organization"
/>
<datalist id="km-suggested-names">
<option value="personal"></option>
<option value="organization"></option>
</datalist>
</div>
<div class="km-form-group">
<label class="km-text" for="passphrase">Vault passphrase</label>
<input
class="km-input"
id="passphrase"
name="passphrase"
type="password"
autocomplete="current-password"
required
/>
</div>
<div class="km-actions">
<button type="submit" class="km-btn km-btn--primary">Save name</button>
<a class="km-btn km-btn--ghost" href="/identities/{{ profile.id }}">Cancel</a>
</div>
</form>
</article>
{% endblock %}
Loading
Loading