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
3 changes: 2 additions & 1 deletion keymaster/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Local **Ed25519** identity tool: **encrypted** `vault.json`, **password-protect*

- **Vault file:** `vault.json` under **`%USERPROFILE%\.modulr\keymaster\`** (Windows) or **`~/.modulr/keymaster/`** (macOS/Linux). Override with env **`KEYMASTER_VAULT_PATH`** (full file path) or **`KEYMASTER_VAULT_DIR`** (directory; filename remains `vault.json`).
- **Crypto:** Argon2id key derivation + AES-GCM envelope. Inner JSON holds a `profiles` array (empty after first create until add-identity work lands).
- **Session:** After unlock or create, an **httpOnly** cookie holds an opaque session id; **private keys stay in server RAM** only until **Lock vault** or process exit.
- **Session:** After unlock or create, an **httpOnly** cookie holds an opaque session id; **private keys stay in server RAM** until **Lock vault**, **idle timeout** (~30 minutes without a request), **max session lifetime** (8 hours), or process exit. Stale unlocks (e.g. lost cookie, unlock again without lock) are purged on the next HTTP request.
- **UI:** FastAPI + Jinja + static CSS aligned with the Modulr customer shell; **fireflies** background (static gradient only if `prefers-reduced-motion: reduce`).

## Run (development)
Expand Down Expand Up @@ -47,6 +47,7 @@ pytest
| `/unlock` | GET/POST | Decrypt vault; POST sets session → `/identities` |
| `/lock` | POST | Clear session → `/unlock` |
| `/identities` | GET | Dashboard (requires session) |
| `/identities/new` | GET/POST | Add Ed25519 profile (session + vault passphrase to re-encrypt disk) |
| `/identities/{id}` | GET | Profile + public key (requires session) |

Static assets: `src/modulr_keymaster/static/`; templates: `templates/`.
141 changes: 131 additions & 10 deletions keymaster/src/modulr_keymaster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
Expand All @@ -13,8 +12,22 @@
from fastapi.templating import Jinja2Templates

from modulr_keymaster.paths import vault_exists, vault_json_path
from modulr_keymaster.profiles import empty_inner_payload, inner_payload_to_profiles
from modulr_keymaster.sessions import SESSION_COOKIE, UnlockedVault, find_profile
from modulr_keymaster.profiles import (
DISPLAY_NAME_MAX_LEN,
empty_inner_payload,
inner_payload_to_profiles,
new_profile,
profiles_to_inner_payload,
)
from modulr_keymaster.sessions import (
SESSION_COOKIE,
UnlockedVault,
find_profile,
new_session_id,
prune_expired_sessions,
replace_session_vault,
resolve_unlocked_vault,
)
from modulr_keymaster.vault_crypto import (
MIN_PASSPHRASE_LENGTH,
VaultCryptoError,
Expand All @@ -37,20 +50,17 @@ def _ctx(request: Request, **extra: object) -> dict[str, object]:

def _session_vault(request: Request) -> UnlockedVault | None:
sid = request.cookies.get(SESSION_COOKIE)
if not sid:
return None
sessions: dict[str, UnlockedVault] = request.app.state.keymaster_sessions
return sessions.get(sid)
sessions = request.app.state.keymaster_sessions
return resolve_unlocked_vault(sessions, sid)


def _bind_session(
response: RedirectResponse,
request: Request,
vault: UnlockedVault,
) -> None:
sid = secrets.token_urlsafe(32)
sessions: dict[str, UnlockedVault] = request.app.state.keymaster_sessions
sessions[sid] = vault
sessions = request.app.state.keymaster_sessions
sid = new_session_id(sessions, vault)
response.set_cookie(
SESSION_COOKIE,
sid,
Expand Down Expand Up @@ -87,6 +97,11 @@ async def lifespan(app: FastAPI):
name="static",
)

@app.middleware("http")
async def expire_stale_sessions(request: Request, call_next):
prune_expired_sessions(request.app.state.keymaster_sessions)
return await call_next(request)

@app.get("/", response_class=RedirectResponse, response_model=None)
async def root() -> RedirectResponse:
if not vault_exists():
Expand Down Expand Up @@ -242,6 +257,112 @@ async def identities(request: Request) -> HTMLResponse | RedirectResponse:
),
)

@app.get("/identities/new", response_model=None)
async def identities_new_get(
request: Request,
) -> HTMLResponse | RedirectResponse:
if _session_vault(request) is None:
return RedirectResponse("/unlock", status_code=302)
return templates.TemplateResponse(
request,
"profile_new.html",
_ctx(request, page_title="New identity", nav_section="new"),
)

@app.post("/identities/new", response_model=None)
async def identities_new_post(
request: Request,
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)

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:
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."
),
),
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):
return templates.TemplateResponse(
request,
"profile_new.html",
_ctx(
request,
page_title="New identity",
nav_section="new",
error="Incorrect passphrase, or the vault file is damaged.",
),
status_code=401,
)

try:
added = new_profile(name)
except ValueError as e:
return templates.TemplateResponse(
request,
"profile_new.html",
_ctx(
request,
page_title="New identity",
nav_section="new",
error=str(e),
),
status_code=400,
)

profiles.append(added)
inner_out = profiles_to_inner_payload(profiles)
try:
envelope_out = encrypt_vault_payload(passphrase, inner_out)
write_envelope(path, envelope_out)
except OSError:
return templates.TemplateResponse(
request,
"profile_new.html",
_ctx(
request,
page_title="New identity",
nav_section="new",
error="Could not write the vault file.",
),
status_code=500,
)

replace_session_vault(sessions, sid, UnlockedVault(profiles))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize all active sessions after writing new identity

POST /identities/new writes the updated profile list to disk but only calls replace_session_vault for the current sid. If the vault is unlocked in another active session (for example, a second browser/profile), that session keeps an out-of-date in-memory UnlockedVault, so /identities and /identities/{id} can show stale results until re-unlock. This inconsistency is introduced by the new mutable add-identity flow and is user-visible whenever multiple session records exist concurrently.

Useful? React with 👍 / 👎.

return RedirectResponse(f"/identities/{added.id}", status_code=303)

@app.get("/identities/{profile_id}", response_model=None)
async def identity_detail(
request: Request,
Expand Down
12 changes: 11 additions & 1 deletion keymaster/src/modulr_keymaster/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,23 @@ def empty_inner_payload() -> dict[str, Any]:
return {"profiles": []}


DISPLAY_NAME_MAX_LEN = 80


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",
)
key = Ed25519PrivateKey.generate()
now = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
return ProfileSecrets(
id=str(uuid.uuid4()),
display_name=display_name.strip() or "Unnamed",
display_name=name,
created_at=now,
private_key=key,
)
83 changes: 82 additions & 1 deletion keymaster/src/modulr_keymaster/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,101 @@

from __future__ import annotations

from dataclasses import dataclass
import secrets
import time
from collections.abc import MutableMapping
from dataclasses import dataclass, field

from modulr_keymaster.profiles import ProfileSecrets

SESSION_COOKIE = "keymaster_session"

# Drop in-memory vault copies when idle (lost cookie / closed tab) or max age reached.
SESSION_IDLE_TIMEOUT_SEC = 30 * 60
SESSION_MAX_LIFETIME_SEC = 8 * 3600


@dataclass
class UnlockedVault:
profiles: list[ProfileSecrets]


@dataclass
class SessionRecord:
vault: UnlockedVault
created_mono: float = field(default_factory=time.monotonic)
last_seen_mono: float = field(default_factory=time.monotonic)

def touch(self) -> None:
self.last_seen_mono = time.monotonic()

def is_expired(self, now: float) -> bool:
if now - self.last_seen_mono > SESSION_IDLE_TIMEOUT_SEC:
return True
if now - self.created_mono > SESSION_MAX_LIFETIME_SEC:
return True
return False


def prune_expired_sessions(
sessions: MutableMapping[str, SessionRecord],
*,
now: float | None = None,
) -> None:
"""Drop sessions past idle or max lifetime (orphaned cookies included)."""
t = time.monotonic() if now is None else now
dead = [sid for sid, rec in sessions.items() if rec.is_expired(t)]
for sid in dead:
del sessions[sid]


def resolve_unlocked_vault(
sessions: MutableMapping[str, SessionRecord],
sid: str | None,
) -> UnlockedVault | None:
"""Return vault for sid if present and not expired; refresh last_seen."""
if not sid:
return None
rec = sessions.get(sid)
if rec is None:
return None
now = time.monotonic()
if rec.is_expired(now):
del sessions[sid]
return None
rec.touch()
return rec.vault


def new_session_id(
sessions: MutableMapping[str, SessionRecord],
vault: UnlockedVault,
) -> str:
"""Register a new unlock session; returns opaque sid."""
prune_expired_sessions(sessions)
sid = secrets.token_urlsafe(32)
sessions[sid] = SessionRecord(vault=vault)
return sid


def find_profile(vault: UnlockedVault, profile_id: str) -> ProfileSecrets | None:
for p in vault.profiles:
if p.id == profile_id:
return p
return None


def replace_session_vault(
sessions: MutableMapping[str, SessionRecord],
sid: str | None,
vault: UnlockedVault,
) -> bool:
"""Swap in-memory profiles after a disk write; returns False if sid missing."""
if not sid:
return False
rec = sessions.get(sid)
if rec is None:
return False
rec.vault = vault
rec.touch()
return True
8 changes: 7 additions & 1 deletion keymaster/src/modulr_keymaster/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,16 @@
>
<a
href="/identities"
class="{% if nav_section == 'identities' or nav_section == 'detail' %}km-nav--active{% endif %}"
class="{% if nav_section == 'identities' or nav_section == 'detail' or nav_section == 'new' %}km-nav--active{% endif %}"
{% if nav_section == 'identities' %}aria-current="page"{% endif %}
>Identities</a
>
<a
href="/identities/new"
class="{% if nav_section == 'new' %}km-nav--active{% endif %}"
{% if nav_section == 'new' %}aria-current="page"{% endif %}
>New identity</a
>
</nav>
</div>

Expand Down
5 changes: 3 additions & 2 deletions keymaster/src/modulr_keymaster/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
<article class="modulr-glass-surface km-panel">
<h1 class="km-display km-text">Identities</h1>
{% if vault_created %}
<p class="km-banner-success" role="status">Vault created. You can add identities in a future update.</p>
<p class="km-banner-success" role="status">Vault created. Add an identity below.</p>
{% endif %}
<p class="km-lead km-text-muted">
Local Ed25519 profiles in your unlocked vault. Registration and name resolution use Modulr.Core —
open a row for public key details and copy-to-clipboard.
</p>
<div class="km-actions" style="margin-top: 0; margin-bottom: 0.5rem">
<a class="km-btn km-btn--primary" href="/identities/new">New identity</a>
<form method="post" action="/lock" style="display: inline">
<button type="submit" class="km-btn km-btn--ghost">Lock vault</button>
</form>
Expand All @@ -26,7 +27,7 @@ <h2 class="km-display km-text">{{ p.display_name }}</h2>
</div>
{% else %}
<p class="km-lead km-text-muted" style="margin-top: 1rem">
No identities yet. This vault is empty — add keys when that flow is available.
No identities yet — use <strong class="km-text">New identity</strong> to generate a key.
</p>
{% endif %}
</article>
Expand Down
Loading
Loading