From 7a5f65ce53ea80a3a91344c7768bf64d2fb8d7a1 Mon Sep 17 00:00:00 2001 From: Undline <103777919+Undline@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:25:02 -0400 Subject: [PATCH] feat(keymaster): add identity, session expiry, vault file permissions - POST /identities/new: passphrase re-encrypts vault.json, append Ed25519 profile, refresh session - Nav + dashboard link; profile detail redirect; copy clarifies same passphrase as unlock - Session idle (30m) and max lifetime (8h) pruning + middleware; replace_session_vault after save - Unix: vault.json 0o600, vault dir 0o700 after write (no chmod on Windows) - Tests: session expiry, add identity HTTP + disk, wrong passphrase unchanged vault Made-with: Cursor --- keymaster/README.md | 3 +- keymaster/src/modulr_keymaster/app.py | 141 ++++++++++++++++-- keymaster/src/modulr_keymaster/profiles.py | 12 +- keymaster/src/modulr_keymaster/sessions.py | 83 ++++++++++- .../src/modulr_keymaster/templates/base.html | 8 +- .../modulr_keymaster/templates/dashboard.html | 5 +- .../templates/profile_new.html | 45 ++++++ keymaster/src/modulr_keymaster/vault_file.py | 19 +++ keymaster/tests/test_add_identity.py | 73 +++++++++ keymaster/tests/test_session_expiry.py | 57 +++++++ 10 files changed, 430 insertions(+), 16 deletions(-) create mode 100644 keymaster/src/modulr_keymaster/templates/profile_new.html create mode 100644 keymaster/tests/test_add_identity.py create mode 100644 keymaster/tests/test_session_expiry.py diff --git a/keymaster/README.md b/keymaster/README.md index c2c4513..db9fc7e 100644 --- a/keymaster/README.md +++ b/keymaster/README.md @@ -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) @@ -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/`. diff --git a/keymaster/src/modulr_keymaster/app.py b/keymaster/src/modulr_keymaster/app.py index c075e7b..18331b0 100644 --- a/keymaster/src/modulr_keymaster/app.py +++ b/keymaster/src/modulr_keymaster/app.py @@ -2,7 +2,6 @@ from __future__ import annotations -import secrets from contextlib import asynccontextmanager from pathlib import Path from typing import Annotated @@ -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, @@ -37,10 +50,8 @@ 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( @@ -48,9 +59,8 @@ def _bind_session( 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, @@ -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(): @@ -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)) + return RedirectResponse(f"/identities/{added.id}", status_code=303) + @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 27393c5..29d564b 100644 --- a/keymaster/src/modulr_keymaster/profiles.py +++ b/keymaster/src/modulr_keymaster/profiles.py @@ -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, ) diff --git a/keymaster/src/modulr_keymaster/sessions.py b/keymaster/src/modulr_keymaster/sessions.py index bef6baf..a837a91 100644 --- a/keymaster/src/modulr_keymaster/sessions.py +++ b/keymaster/src/modulr_keymaster/sessions.py @@ -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 diff --git a/keymaster/src/modulr_keymaster/templates/base.html b/keymaster/src/modulr_keymaster/templates/base.html index dadb16a..859e41a 100644 --- a/keymaster/src/modulr_keymaster/templates/base.html +++ b/keymaster/src/modulr_keymaster/templates/base.html @@ -58,10 +58,16 @@ > Identities + New identity diff --git a/keymaster/src/modulr_keymaster/templates/dashboard.html b/keymaster/src/modulr_keymaster/templates/dashboard.html index 94c6f0b..4ae2982 100644 --- a/keymaster/src/modulr_keymaster/templates/dashboard.html +++ b/keymaster/src/modulr_keymaster/templates/dashboard.html @@ -3,13 +3,14 @@

Identities

{% if vault_created %} -

Vault created. You can add identities in a future update.

+

Vault created. Add an identity below.

{% endif %}

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.

+ New identity
@@ -26,7 +27,7 @@

{{ p.display_name }}

{% else %}

- No identities yet. This vault is empty — add keys when that flow is available. + No identities yet — use New identity to generate a key.

{% endif %}
diff --git a/keymaster/src/modulr_keymaster/templates/profile_new.html b/keymaster/src/modulr_keymaster/templates/profile_new.html new file mode 100644 index 0000000..81dcc85 --- /dev/null +++ b/keymaster/src/modulr_keymaster/templates/profile_new.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block content %} +
+

New identity

+

+ Creates a new Ed25519 key in this vault. Use the same passphrase you + use to unlock the vault (not a new password) — we need it to re-encrypt + vault.json on disk. Suggested names: personal, + organization. +

+ {% if error %} + + {% endif %} +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+{% endblock %} diff --git a/keymaster/src/modulr_keymaster/vault_file.py b/keymaster/src/modulr_keymaster/vault_file.py index 8e98f67..25cf35d 100644 --- a/keymaster/src/modulr_keymaster/vault_file.py +++ b/keymaster/src/modulr_keymaster/vault_file.py @@ -3,10 +3,22 @@ from __future__ import annotations import json +import os +import stat from pathlib import Path from typing import Any +def _chmod_owner_only(path: Path) -> None: + """Unix: 0o600. Windows ACL model differs; skip chmod there.""" + if os.name == "nt": + return + try: + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + + def read_envelope(path: Path) -> dict[str, Any]: text = path.read_text(encoding="utf-8") data = json.loads(text) @@ -17,7 +29,14 @@ def read_envelope(path: Path) -> dict[str, Any]: def write_envelope(path: Path, envelope: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + try: + path.parent.chmod(0o700) + except OSError: + pass tmp = path.with_suffix(".tmp") payload = json.dumps(envelope, indent=2, sort_keys=True) + "\n" tmp.write_text(payload, encoding="utf-8") + _chmod_owner_only(tmp) tmp.replace(path) + _chmod_owner_only(path) diff --git a/keymaster/tests/test_add_identity.py b/keymaster/tests/test_add_identity.py new file mode 100644 index 0000000..1781277 --- /dev/null +++ b/keymaster/tests/test_add_identity.py @@ -0,0 +1,73 @@ +"""Add identity: POST /identities/new persists to vault.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from modulr_keymaster.app import create_app +from modulr_keymaster.profiles import inner_payload_to_profiles +from modulr_keymaster.vault_crypto import decrypt_vault_payload +from modulr_keymaster.vault_file import read_envelope + +PASS = "twelve-chars!" + + +def test_add_identity_http_and_disk(tmp_path: Path, monkeypatch) -> None: + vault = tmp_path / "vault.json" + monkeypatch.setenv("KEYMASTER_VAULT_PATH", str(vault)) + + with TestClient(create_app()) as client: + r0 = client.post( + "/setup", + data={"pw1": PASS, "pw2": PASS}, + follow_redirects=False, + ) + assert r0.status_code == 303 + r1 = client.post( + "/identities/new", + data={"display_name": "Personal", "passphrase": PASS}, + follow_redirects=False, + ) + assert r1.status_code == 303 + loc = r1.headers.get("location") or "" + assert loc.startswith("/identities/") + pid = loc.rsplit("/", 1)[-1] + assert pid + + page = client.get("/identities") + assert page.status_code == 200 + assert b"Personal" in page.content + + detail = client.get(f"/identities/{pid}") + assert detail.status_code == 200 + + 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 == "Personal" + assert profs[0].id == pid + + +def test_add_identity_wrong_passphrase(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 = client.post( + "/identities/new", + data={"display_name": "X", "passphrase": "wrong-passphrase-here"}, + follow_redirects=False, + ) + assert r.status_code == 401 + + env = read_envelope(vault) + inner = decrypt_vault_payload(PASS, env) + assert len(inner_payload_to_profiles(inner)) == 0 diff --git a/keymaster/tests/test_session_expiry.py b/keymaster/tests/test_session_expiry.py new file mode 100644 index 0000000..489c2df --- /dev/null +++ b/keymaster/tests/test_session_expiry.py @@ -0,0 +1,57 @@ +"""Session idle / max-lifetime pruning.""" + +from __future__ import annotations + +import pytest + +from modulr_keymaster.sessions import ( + SESSION_IDLE_TIMEOUT_SEC, + SESSION_MAX_LIFETIME_SEC, + SessionRecord, + UnlockedVault, + prune_expired_sessions, + resolve_unlocked_vault, +) + + +def test_prune_removes_idle_session() -> None: + vault = UnlockedVault([]) + now = 10_000.0 + sessions = { + "sid": SessionRecord( + vault=vault, + created_mono=now - 60.0, + last_seen_mono=now - SESSION_IDLE_TIMEOUT_SEC - 1.0, + ), + } + prune_expired_sessions(sessions, now=now) + assert "sid" not in sessions + + +def test_prune_removes_max_lifetime_session() -> None: + vault = UnlockedVault([]) + now = 10_000.0 + sessions = { + "sid": SessionRecord( + vault=vault, + created_mono=now - SESSION_MAX_LIFETIME_SEC - 1.0, + last_seen_mono=now, + ), + } + prune_expired_sessions(sessions, now=now) + assert "sid" not in sessions + + +def test_resolve_drops_expired_sid(monkeypatch: pytest.MonkeyPatch) -> None: + vault = UnlockedVault([]) + now = 10_000.0 + monkeypatch.setattr("modulr_keymaster.sessions.time.monotonic", lambda: now) + sessions = { + "gone": SessionRecord( + vault=vault, + created_mono=now - SESSION_MAX_LIFETIME_SEC - 5.0, + last_seen_mono=now - 1.0, + ), + } + assert resolve_unlocked_vault(sessions, "gone") is None + assert "gone" not in sessions \ No newline at end of file