From b4abf65af0b79729feb3aa2547cef1fa5d00e493 Mon Sep 17 00:00:00 2001 From: Undline <103777919+Undline@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:36:38 -0400 Subject: [PATCH] feat(keymaster): encrypted vault, sessions, and dependency notes - Argon2id + AES-GCM vault.json under ~/.modulr/keymaster (KEYMASTER_VAULT_* overrides) - POST setup/unlock, lock; httpOnly session; empty profiles until add-identity ships - Fireflies UI kept; remove preview banner; copy and forms wired - PyPI trust notes in pyproject; tests (vault crypto + setup flow) - Gitignore local vault paths Made-with: Cursor --- .gitignore | 5 + keymaster/README.md | 42 +-- keymaster/pyproject.toml | 24 ++ keymaster/src/modulr_keymaster/app.py | 271 ++++++++++++++---- keymaster/src/modulr_keymaster/cli.py | 2 - keymaster/src/modulr_keymaster/paths.py | 26 ++ keymaster/src/modulr_keymaster/profiles.py | 121 ++++++++ keymaster/src/modulr_keymaster/sessions.py | 21 ++ .../src/modulr_keymaster/static/keymaster.css | 22 ++ .../src/modulr_keymaster/templates/base.html | 5 - .../modulr_keymaster/templates/dashboard.html | 35 ++- .../src/modulr_keymaster/templates/setup.html | 65 +++-- .../modulr_keymaster/templates/unlock.html | 30 +- .../src/modulr_keymaster/vault_crypto.py | 108 +++++++ keymaster/src/modulr_keymaster/vault_file.py | 23 ++ keymaster/tests/test_app_vault_flow.py | 25 ++ keymaster/tests/test_vault_crypto.py | 27 ++ 17 files changed, 732 insertions(+), 120 deletions(-) create mode 100644 keymaster/src/modulr_keymaster/paths.py create mode 100644 keymaster/src/modulr_keymaster/profiles.py create mode 100644 keymaster/src/modulr_keymaster/sessions.py create mode 100644 keymaster/src/modulr_keymaster/vault_crypto.py create mode 100644 keymaster/src/modulr_keymaster/vault_file.py create mode 100644 keymaster/tests/test_app_vault_flow.py create mode 100644 keymaster/tests/test_vault_crypto.py diff --git a/.gitignore b/.gitignore index 1a32cb7..c4749cd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ build/ # Local SQLite (modulr-core default database_path) data/ +# Keymaster — local vault (encrypted blobs still belong only on disk; keeps clones clean) +keymaster_data/ +keymaster/keymaster_data/ +.modulr/keymaster/ + # Testing / tools .pytest_cache/ .ruff_cache/ diff --git a/keymaster/README.md b/keymaster/README.md index 40b140d..c2c4513 100644 --- a/keymaster/README.md +++ b/keymaster/README.md @@ -1,6 +1,6 @@ # Keymaster (local) -Local **Ed25519** identity tool: generate **named** key profiles, **password-protect** the vault, browse via a **loopback web UI**. +Local **Ed25519** identity tool: **encrypted** `vault.json`, **password-protect** the vault, browse via a **loopback web UI**. **Full plan:** [`plan/keymaster_local_wallet.md`](../plan/keymaster_local_wallet.md) @@ -8,7 +8,10 @@ Local **Ed25519** identity tool: generate **named** key profiles, **password-pro ## Status -The **web UI shell** is implemented (FastAPI + Jinja + static CSS aligned with the Modulr customer shell). Background matches the Core shell **fireflies** preset (canvas + gradient). With **`prefers-reduced-motion: reduce`**, only the static gradient runs. Vault encryption, key generation, and persistence are **not** wired yet — you will see an “UI preview” banner. +- **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. +- **UI:** FastAPI + Jinja + static CSS aligned with the Modulr customer shell; **fireflies** background (static gradient only if `prefers-reduced-motion: reduce`). ## Run (development) @@ -19,26 +22,31 @@ pip install -e ./keymaster modulr-keymaster --reload ``` -Or from `keymaster/`: +Install dev deps for tests: `pip install -e "./keymaster[dev]"`. -```powershell -pip install -e . -modulr-keymaster --reload -``` - -Defaults: **127.0.0.1:8765**. Open [http://127.0.0.1:8765](http://127.0.0.1:8765) (redirects to `/unlock`). +Defaults: **127.0.0.1:8765**. Open [http://127.0.0.1:8765](http://127.0.0.1:8765): no vault → **Create vault**; vault present → **Unlock**. - **`--port`** — listen port - **`--host`** — bind address (avoid `0.0.0.0`; this tool is meant for loopback only) - **`--reload`** — auto-reload on code changes -## Layout +## Tests + +From `keymaster/`: + +```powershell +pytest +``` + +## Routes -| Path | Screen | -|------|--------| -| `/unlock` | Unlock vault | -| `/setup` | First-run / create vault | -| `/identities` | Dashboard (mock profiles) | -| `/identities/{id}` | Profile detail + copy public key | +| Path | Method | Behavior | +|------|--------|----------| +| `/` | GET | Redirect: no vault → `/setup`, else `/unlock` | +| `/setup` | GET/POST | Create `vault.json` (passphrase ≥ 12 chars, confirm match); then session + redirect `/identities` | +| `/unlock` | GET/POST | Decrypt vault; POST sets session → `/identities` | +| `/lock` | POST | Clear session → `/unlock` | +| `/identities` | GET | Dashboard (requires session) | +| `/identities/{id}` | GET | Profile + public key (requires session) | -Static theme files live under `src/modulr_keymaster/static/`; templates under `templates/`. +Static assets: `src/modulr_keymaster/static/`; templates: `templates/`. diff --git a/keymaster/pyproject.toml b/keymaster/pyproject.toml index 7d7a26a..33d5493 100644 --- a/keymaster/pyproject.toml +++ b/keymaster/pyproject.toml @@ -10,12 +10,32 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "BSL-1.1" } authors = [{ name = "Modulr" }] + +# Runtime dependencies — install only from PyPI (or your own index) after verifying the project page. +# Check: https://pypi.org/project// (maintainer, homepage, release history). Pin upgrades deliberately. +# +# Vault / crypto (added for encrypted vault.json): +# cryptography — PyCA; Ed25519 + AES-GCM. Same stack as Modulr.Core (root pyproject). Repo: github.com/pyca/cryptography +# argon2-cffi — Hynek Schlawack; Argon2id KDF for passphrase → key. Repo: github.com/hynek/argon2-cffi +# +# Web / forms: +# python-multipart — Andrew Dunham; parses multipart form bodies (POST from our HTML forms). Repo: github.com/Kludex/python-multipart +# fastapi — Sebastián Ramírez; ASGI framework. Repo: github.com/fastapi/fastapi +# jinja2 — Pallets; server-side HTML templates. Repo: github.com/pallets/jinja +# uvicorn — Encode; ASGI server. Repo: github.com/Kludex/uvicorn dependencies = [ + "argon2-cffi>=23.1.0", + "cryptography>=42.0.0", "fastapi>=0.115.0", "jinja2>=3.1.0", + "python-multipart>=0.0.9", "uvicorn[standard]>=0.30.0", ] +# Dev-only: pytest (tests), httpx (FastAPI TestClient). PyPI: pytest, httpx. +[project.optional-dependencies] +dev = ["pytest>=8.0", "httpx>=0.27.0"] + [project.scripts] modulr-keymaster = "modulr_keymaster.cli:main" @@ -26,6 +46,10 @@ packages = ["src/modulr_keymaster"] "src/modulr_keymaster/static" = "modulr_keymaster/static" "src/modulr_keymaster/templates" = "modulr_keymaster/templates" +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + [tool.ruff] target-version = "py311" line-length = 88 diff --git a/keymaster/src/modulr_keymaster/app.py b/keymaster/src/modulr_keymaster/app.py index 5faf12b..c075e7b 100644 --- a/keymaster/src/modulr_keymaster/app.py +++ b/keymaster/src/modulr_keymaster/app.py @@ -1,47 +1,85 @@ -"""FastAPI app: themed static UI for Keymaster (mock data until vault exists).""" +"""FastAPI app: Keymaster loopback UI and encrypted vault.""" from __future__ import annotations +import secrets +from contextlib import asynccontextmanager from pathlib import Path +from typing import Annotated -from fastapi import FastAPI, Request +from fastapi import FastAPI, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse 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 empty_inner_payload, inner_payload_to_profiles +from modulr_keymaster.sessions import SESSION_COOKIE, UnlockedVault, find_profile +from modulr_keymaster.vault_crypto import ( + MIN_PASSPHRASE_LENGTH, + VaultCryptoError, + decrypt_vault_payload, + encrypt_vault_payload, +) +from modulr_keymaster.vault_file import read_envelope, write_envelope + _PKG_DIR = Path(__file__).resolve().parent -MOCK_PROFILES: list[dict[str, str]] = [ - { - "id": "personal", - "display_name": "Personal", - "public_key_hex": ( - "3f8a2c1e9b0d4f7a6e5c8b1d2f0a3948" - "7e6d5c4b3a291807f6e5d4c3b2a1908f" - ), - "created_at": "2026-03-28T14:22:00Z", - }, - { - "id": "organization", - "display_name": "Organization", - "public_key_hex": ( - "a1b2c3d4e5f60718293a4b5c6d7e8f90" - "0f1e2d3c4b5a69788796a5b4c3d2e1f0" - ), - "created_at": "2026-03-29T09:15:00Z", - }, -] - - -def _profile_by_id(profile_id: str) -> dict[str, str] | None: - for p in MOCK_PROFILES: - if p["id"] == profile_id: - return p - return None + +def _ctx(request: Request, **extra: object) -> dict[str, object]: + out: dict[str, object] = { + "request": request, + "nav_section": "none", + } + out.update(extra) + return out + + +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) + + +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 + response.set_cookie( + SESSION_COOKIE, + sid, + httponly=True, + samesite="lax", + path="/", + ) + + +def _clear_session(response: RedirectResponse, request: Request) -> None: + sid = request.cookies.get(SESSION_COOKIE) + if sid: + request.app.state.keymaster_sessions.pop(sid, None) + response.delete_cookie(SESSION_COOKIE, path="/") def create_app() -> FastAPI: - app = FastAPI(title="Keymaster", version="0.1.0", docs_url=None, redoc_url=None) + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.keymaster_sessions = {} + yield + + app = FastAPI( + title="Keymaster", + version="0.1.0", + docs_url=None, + redoc_url=None, + lifespan=lifespan, + ) templates = Jinja2Templates(directory=str(_PKG_DIR / "templates")) app.mount( "/static", @@ -49,65 +87,184 @@ def create_app() -> FastAPI: name="static", ) - def ctx(request: Request, **extra: object) -> dict[str, object]: - out: dict[str, object] = { - "request": request, - "ui_preview": True, - "nav_section": "none", - } - out.update(extra) - return out - - @app.get("/", response_class=RedirectResponse) + @app.get("/", response_class=RedirectResponse, response_model=None) async def root() -> RedirectResponse: - return RedirectResponse(url="/unlock", status_code=302) + if not vault_exists(): + return RedirectResponse("/setup", status_code=302) + return RedirectResponse("/unlock", status_code=302) - @app.get("/unlock", response_class=HTMLResponse) - async def unlock(request: Request) -> HTMLResponse: + @app.get("/unlock", response_model=None) + async def unlock_get(request: Request) -> HTMLResponse | RedirectResponse: + if not vault_exists(): + return RedirectResponse("/setup", status_code=302) + if _session_vault(request) is not None: + return RedirectResponse("/identities", status_code=302) return templates.TemplateResponse( request, "unlock.html", - ctx(request, page_title="Unlock vault", nav_section="unlock"), + _ctx(request, page_title="Unlock vault", nav_section="unlock"), ) + @app.post("/unlock", response_model=None) + async def unlock_post( + request: Request, + passphrase: Annotated[str, Form()], + ) -> HTMLResponse: + if not vault_exists(): + return RedirectResponse("/setup", status_code=303) + path = vault_json_path() + try: + envelope = read_envelope(path) + inner = decrypt_vault_payload(passphrase, envelope) + profiles = inner_payload_to_profiles(inner) + except (OSError, ValueError, VaultCryptoError): + return templates.TemplateResponse( + request, + "unlock.html", + _ctx( + request, + page_title="Unlock vault", + nav_section="unlock", + error="Incorrect passphrase, or the vault file is damaged.", + ), + status_code=401, + ) + vault = UnlockedVault(profiles) + response = RedirectResponse("/identities", status_code=303) + _bind_session(response, request, vault) + return response + @app.get("/setup", response_class=HTMLResponse) - async def setup(request: Request) -> HTMLResponse: + async def setup_get(request: Request) -> HTMLResponse: + if vault_exists(): + return templates.TemplateResponse( + request, + "setup.html", + _ctx( + request, + page_title="Create vault", + nav_section="setup", + vault_already_exists=True, + ), + ) return templates.TemplateResponse( request, "setup.html", - ctx(request, page_title="Create vault", nav_section="setup"), + _ctx(request, page_title="Create vault", nav_section="setup"), ) - @app.get("/identities", response_class=HTMLResponse) - async def identities(request: Request) -> HTMLResponse: + @app.post("/setup", response_model=None) + async def setup_post( + request: Request, + pw1: Annotated[str, Form()], + pw2: Annotated[str, Form()], + ) -> HTMLResponse | RedirectResponse: + if vault_exists(): + return templates.TemplateResponse( + request, + "setup.html", + _ctx( + request, + page_title="Create vault", + nav_section="setup", + vault_already_exists=True, + error="A vault already exists on this machine. Unlock it instead.", + ), + status_code=400, + ) + err: str | None = None + if pw1 != pw2: + err = "Passphrases do not match." + elif len(pw1) < MIN_PASSPHRASE_LENGTH: + err = f"Passphrase must be at least {MIN_PASSPHRASE_LENGTH} characters." + + if err: + return templates.TemplateResponse( + request, + "setup.html", + _ctx( + request, + page_title="Create vault", + nav_section="setup", + error=err, + ), + status_code=400, + ) + + inner = empty_inner_payload() + envelope = encrypt_vault_payload(pw1, inner) + try: + write_envelope(vault_json_path(), envelope) + except OSError: + return templates.TemplateResponse( + request, + "setup.html", + _ctx( + request, + page_title="Create vault", + nav_section="setup", + error=( + "Could not write the vault file. " + "Check disk space and permissions." + ), + ), + status_code=500, + ) + + profiles = inner_payload_to_profiles(inner) + vault = UnlockedVault(profiles) + response = RedirectResponse("/identities?created=1", status_code=303) + _bind_session(response, request, vault) + return response + + @app.post("/lock", response_class=RedirectResponse) + async def lock_post(request: Request) -> RedirectResponse: + response = RedirectResponse("/unlock", status_code=303) + _clear_session(response, request) + return response + + @app.get("/identities", response_model=None) + async def identities(request: Request) -> HTMLResponse | RedirectResponse: + vault = _session_vault(request) + if vault is None: + return RedirectResponse("/unlock", status_code=302) + rows = [p.to_public_dict() for p in vault.profiles] + created = request.query_params.get("created") == "1" return templates.TemplateResponse( request, "dashboard.html", - ctx( + _ctx( request, page_title="Identities", - profiles=MOCK_PROFILES, + profiles=rows, nav_section="identities", + vault_created=created, ), ) - @app.get("/identities/{profile_id}", response_class=HTMLResponse) - async def identity_detail(request: Request, profile_id: str) -> HTMLResponse: - profile = _profile_by_id(profile_id) + @app.get("/identities/{profile_id}", response_model=None) + async def identity_detail( + 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"), + _ctx(request, page_title="Not found", nav_section="none"), status_code=404, ) return templates.TemplateResponse( request, "profile_detail.html", - ctx( + _ctx( request, - page_title=profile["display_name"], - profile=profile, + page_title=profile.display_name, + profile=profile.to_public_dict(), nav_section="detail", ), ) diff --git a/keymaster/src/modulr_keymaster/cli.py b/keymaster/src/modulr_keymaster/cli.py index 4d2b8ff..ca2d0bc 100644 --- a/keymaster/src/modulr_keymaster/cli.py +++ b/keymaster/src/modulr_keymaster/cli.py @@ -7,8 +7,6 @@ import uvicorn -from modulr_keymaster.app import create_app - def main() -> None: parser = argparse.ArgumentParser(description="Keymaster local web UI") diff --git a/keymaster/src/modulr_keymaster/paths.py b/keymaster/src/modulr_keymaster/paths.py new file mode 100644 index 0000000..5c9136d --- /dev/null +++ b/keymaster/src/modulr_keymaster/paths.py @@ -0,0 +1,26 @@ +"""Filesystem locations for Keymaster data (outside the git clone by default).""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def vault_dir() -> Path: + """Directory containing `vault.json`; created on first vault setup.""" + override = os.environ.get("KEYMASTER_VAULT_DIR", "").strip() + if override: + return Path(override).expanduser().resolve() + return (Path.home() / ".modulr" / "keymaster").resolve() + + +def vault_json_path() -> Path: + """Absolute path to the encrypted vault file.""" + override = os.environ.get("KEYMASTER_VAULT_PATH", "").strip() + if override: + return Path(override).expanduser().resolve() + return vault_dir() / "vault.json" + + +def vault_exists() -> bool: + return vault_json_path().is_file() diff --git a/keymaster/src/modulr_keymaster/profiles.py b/keymaster/src/modulr_keymaster/profiles.py new file mode 100644 index 0000000..27393c5 --- /dev/null +++ b/keymaster/src/modulr_keymaster/profiles.py @@ -0,0 +1,121 @@ +"""In-memory Ed25519 profiles loaded from the vault.""" + +from __future__ import annotations + +import base64 +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from modulr_keymaster.vault_crypto import VaultCryptoError + + +@dataclass +class ProfileSecrets: + """Holds private key material only in RAM after unlock.""" + + id: str + display_name: str + created_at: str + private_key: Ed25519PrivateKey + + def public_key_hex(self) -> str: + raw = self.private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return raw.hex() + + def to_public_dict(self) -> dict[str, str]: + return { + "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: + seed = base64.standard_b64decode(seed_b64) + except (ValueError, TypeError) as e: + raise VaultCryptoError("invalid profile seed encoding") from e + if len(seed) != 32: + raise VaultCryptoError("invalid Ed25519 seed length") + return Ed25519PrivateKey.from_private_bytes(seed) + + +def _key_to_seed_b64(key: Ed25519PrivateKey) -> str: + raw = key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + return base64.standard_b64encode(raw).decode("ascii") + + +def inner_payload_to_profiles(inner: dict[str, Any]) -> list[ProfileSecrets]: + profiles_raw = inner.get("profiles") + if profiles_raw is None: + raise VaultCryptoError("missing profiles array") + if not isinstance(profiles_raw, list): + raise VaultCryptoError("profiles must be a list") + + out: list[ProfileSecrets] = [] + for item in profiles_raw: + if not isinstance(item, dict): + raise VaultCryptoError("invalid profile entry") + pid = item.get("id") + name = item.get("display_name") + created = item.get("created_at") + seed_b64 = item.get("ed25519_seed_b64") + if not isinstance(pid, str) or not isinstance(name, str): + raise VaultCryptoError("invalid profile fields") + if not isinstance(created, str): + raise VaultCryptoError("invalid profile created_at") + if not isinstance(seed_b64, str): + raise VaultCryptoError("invalid profile seed") + key = _seed_b64_to_key(seed_b64) + out.append( + ProfileSecrets( + id=pid, + display_name=name, + created_at=created, + private_key=key, + ), + ) + return out + + +def profiles_to_inner_payload(profiles: list[ProfileSecrets]) -> dict[str, Any]: + rows: list[dict[str, str]] = [] + for p in profiles: + rows.append( + { + "id": p.id, + "display_name": p.display_name, + "created_at": p.created_at, + "ed25519_seed_b64": _key_to_seed_b64(p.private_key), + }, + ) + return {"profiles": rows} + + +def empty_inner_payload() -> dict[str, Any]: + return {"profiles": []} + + +def new_profile(display_name: str) -> ProfileSecrets: + """Generate a new random Ed25519 identity.""" + 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", + created_at=now, + private_key=key, + ) diff --git a/keymaster/src/modulr_keymaster/sessions.py b/keymaster/src/modulr_keymaster/sessions.py new file mode 100644 index 0000000..bef6baf --- /dev/null +++ b/keymaster/src/modulr_keymaster/sessions.py @@ -0,0 +1,21 @@ +"""Server-side unlock state (keys stay in RAM; cookie holds opaque session id only).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from modulr_keymaster.profiles import ProfileSecrets + +SESSION_COOKIE = "keymaster_session" + + +@dataclass +class UnlockedVault: + profiles: list[ProfileSecrets] + + +def find_profile(vault: UnlockedVault, profile_id: str) -> ProfileSecrets | None: + for p in vault.profiles: + if p.id == profile_id: + return p + return None diff --git a/keymaster/src/modulr_keymaster/static/keymaster.css b/keymaster/src/modulr_keymaster/static/keymaster.css index 1edaaaf..17425ec 100644 --- a/keymaster/src/modulr_keymaster/static/keymaster.css +++ b/keymaster/src/modulr_keymaster/static/keymaster.css @@ -432,6 +432,28 @@ html[data-theme="light"] .km-bg-base { line-height: 1.5; } +.km-form-error { + margin: 0 0 1rem; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; + border: 1px solid color-mix(in srgb, #f87171 55%, var(--modulr-glass-border)); + background: color-mix(in srgb, #f87171 12%, var(--modulr-glass-panel-fill)); + color: var(--modulr-text); + font-size: 0.875rem; + line-height: 1.45; +} + +.km-banner-success { + margin: 0 0 1rem; + padding: 0.65rem 0.85rem; + border-radius: 0.5rem; + border: 1px solid color-mix(in srgb, var(--modulr-accent) 45%, var(--modulr-glass-border)); + background: color-mix(in srgb, var(--modulr-accent) 14%, var(--modulr-glass-panel-fill)); + color: var(--modulr-text); + font-size: 0.875rem; + line-height: 1.45; +} + .km-skip-row { margin-top: 1rem; font-size: 0.8125rem; diff --git a/keymaster/src/modulr_keymaster/templates/base.html b/keymaster/src/modulr_keymaster/templates/base.html index 423179f..dadb16a 100644 --- a/keymaster/src/modulr_keymaster/templates/base.html +++ b/keymaster/src/modulr_keymaster/templates/base.html @@ -105,11 +105,6 @@
- {% if ui_preview %} -

- UI preview — encrypted vault and signing are not wired yet -

- {% endif %} {% block content %}{% endblock %}
diff --git a/keymaster/src/modulr_keymaster/templates/dashboard.html b/keymaster/src/modulr_keymaster/templates/dashboard.html index 4a81aaf..94c6f0b 100644 --- a/keymaster/src/modulr_keymaster/templates/dashboard.html +++ b/keymaster/src/modulr_keymaster/templates/dashboard.html @@ -2,21 +2,32 @@ {% block content %}

Identities

+ {% if vault_created %} +

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

+ {% endif %}

- Local Ed25519 profiles in your vault (mock data). Registration and name resolution use - Modulr.Core — open a row for public key details and copy-to-clipboard. + 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.

- + {% if profiles %} + + {% else %} +

+ No identities yet. This vault is empty — add keys when that flow is available. +

+ {% endif %}
{% endblock %} diff --git a/keymaster/src/modulr_keymaster/templates/setup.html b/keymaster/src/modulr_keymaster/templates/setup.html index ca1302b..14f1c0d 100644 --- a/keymaster/src/modulr_keymaster/templates/setup.html +++ b/keymaster/src/modulr_keymaster/templates/setup.html @@ -2,23 +2,56 @@ {% block content %}

Create vault

-

- First-run setup: choose a strong passphrase. Keys never leave this machine. This screen is - visual only until the vault module exists. -

-
-
- - -
-
- - -
+ {% if vault_already_exists %} + {% if error %} + + {% endif %} +

+ A vault file already exists on this computer (vault.json under your + Modulr folder). Unlock it to use your identities. +

- - Already have a vault + Unlock vault
-
+ {% else %} +

+ Choose a strong passphrase. It encrypts vault.json on disk; keys + never leave this machine. After you submit, the vault is created and you are signed in for this + browser session. +

+ {% if error %} + + {% endif %} +
+
+ + +
+
+ + +
+
+ + Already have a vault +
+
+ {% endif %}
{% endblock %} diff --git a/keymaster/src/modulr_keymaster/templates/unlock.html b/keymaster/src/modulr_keymaster/templates/unlock.html index bdd29ee..90048d0 100644 --- a/keymaster/src/modulr_keymaster/templates/unlock.html +++ b/keymaster/src/modulr_keymaster/templates/unlock.html @@ -3,24 +3,32 @@

Unlock vault

- Enter the passphrase for your local vault. Nothing is persisted in this preview — the form is - for layout only. + Enter your passphrase. Your keys stay in memory until you lock the vault or close the browser + session.

-
+ {% if error %} + + {% endif %} +
- - + +

- Production flow will decrypt your vault in memory only and keep the bind on loopback. -

-

- Skip to identities (mock data) to review the dashboard layout. + Keymaster only listens on loopback. Do not expose this port to a network you do not trust.

{% endblock %} diff --git a/keymaster/src/modulr_keymaster/vault_crypto.py b/keymaster/src/modulr_keymaster/vault_crypto.py new file mode 100644 index 0000000..eec0ef1 --- /dev/null +++ b/keymaster/src/modulr_keymaster/vault_crypto.py @@ -0,0 +1,108 @@ +"""Encrypt/decrypt vault envelope (Argon2id + AES-GCM).""" + +from __future__ import annotations + +import base64 +import json +import os +from typing import Any + +from argon2.low_level import Type, hash_secret_raw +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +VAULT_VERSION = 1 +MIN_PASSPHRASE_LENGTH = 12 + +# Argon2id parameters (tuned for interactive unlock on a laptop). +KDF_TIME_COST = 3 +KDF_MEMORY_KIB = 65536 # 64 MiB +KDF_PARALLELISM = 4 +KDF_HASH_LEN = 32 +KDF_SALT_LEN = 16 + +AES_KEY_LEN = 32 +GCM_NONCE_LEN = 12 + + +class VaultCryptoError(Exception): + """Invalid vault data or wrong passphrase.""" + + +def _derive_key(passphrase: str, salt: bytes) -> bytes: + return hash_secret_raw( + secret=passphrase.encode("utf-8"), + salt=salt, + time_cost=KDF_TIME_COST, + memory_cost=KDF_MEMORY_KIB, + parallelism=KDF_PARALLELISM, + hash_len=KDF_HASH_LEN, + type=Type.ID, + ) + + +def encrypt_vault_payload(passphrase: str, inner: dict[str, Any]) -> dict[str, Any]: + """Build outer JSON dict suitable for writing `vault.json`.""" + salt = os.urandom(KDF_SALT_LEN) + key = _derive_key(passphrase, salt) + if len(key) != AES_KEY_LEN: + raise VaultCryptoError("unexpected derived key length") + nonce = os.urandom(GCM_NONCE_LEN) + plaintext = json.dumps(inner, separators=(",", ":"), sort_keys=True).encode("utf-8") + aes = AESGCM(key) + ciphertext = aes.encrypt(nonce, plaintext, associated_data=None) + return { + "vault_version": VAULT_VERSION, + "kdf": "argon2id", + "kdf_salt_b64": base64.standard_b64encode(salt).decode("ascii"), + "kdf_time_cost": KDF_TIME_COST, + "kdf_memory_kib": KDF_MEMORY_KIB, + "kdf_parallelism": KDF_PARALLELISM, + "payload_nonce_b64": base64.standard_b64encode(nonce).decode("ascii"), + "payload_ciphertext_b64": base64.standard_b64encode(ciphertext).decode( + "ascii", + ), + } + + +def decrypt_vault_payload(passphrase: str, envelope: dict[str, Any]) -> dict[str, Any]: + """Decrypt envelope; returns inner JSON object (e.g. ``{"profiles": [...]}``).""" + try: + version = int(envelope["vault_version"]) + except (KeyError, TypeError, ValueError) as e: + raise VaultCryptoError("invalid vault envelope") from e + if version != VAULT_VERSION: + raise VaultCryptoError(f"unsupported vault_version {version}") + + try: + salt = base64.standard_b64decode(envelope["kdf_salt_b64"]) + nonce = base64.standard_b64decode(envelope["payload_nonce_b64"]) + ciphertext = base64.standard_b64decode(envelope["payload_ciphertext_b64"]) + time_cost = int(envelope["kdf_time_cost"]) + memory_kib = int(envelope["kdf_memory_kib"]) + parallelism = int(envelope["kdf_parallelism"]) + except (KeyError, TypeError, ValueError) as e: + raise VaultCryptoError("corrupt vault envelope") from e + + key = hash_secret_raw( + secret=passphrase.encode("utf-8"), + salt=salt, + time_cost=time_cost, + memory_cost=memory_kib, + parallelism=parallelism, + hash_len=KDF_HASH_LEN, + type=Type.ID, + ) + aes = AESGCM(key) + try: + plaintext = aes.decrypt(nonce, ciphertext, associated_data=None) + except InvalidTag as e: + raise VaultCryptoError("incorrect passphrase or corrupt vault") from e + + try: + inner = json.loads(plaintext.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise VaultCryptoError("corrupt vault plaintext") from e + if not isinstance(inner, dict): + raise VaultCryptoError("invalid vault structure") + return inner diff --git a/keymaster/src/modulr_keymaster/vault_file.py b/keymaster/src/modulr_keymaster/vault_file.py new file mode 100644 index 0000000..8e98f67 --- /dev/null +++ b/keymaster/src/modulr_keymaster/vault_file.py @@ -0,0 +1,23 @@ +"""Read/write `vault.json` with atomic replace.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def read_envelope(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError("vault file must contain a JSON object") + return data + + +def write_envelope(path: Path, envelope: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + payload = json.dumps(envelope, indent=2, sort_keys=True) + "\n" + tmp.write_text(payload, encoding="utf-8") + tmp.replace(path) diff --git a/keymaster/tests/test_app_vault_flow.py b/keymaster/tests/test_app_vault_flow.py new file mode 100644 index 0000000..1ae7215 --- /dev/null +++ b/keymaster/tests/test_app_vault_flow.py @@ -0,0 +1,25 @@ +"""HTTP flow: create vault writes file and sets session.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from modulr_keymaster.app import create_app + + +def test_post_setup_creates_vault_and_cookie(tmp_path: Path, monkeypatch) -> None: + vault = tmp_path / "vault.json" + monkeypatch.setenv("KEYMASTER_VAULT_PATH", str(vault)) + + with TestClient(create_app()) as client: + response = client.post( + "/setup", + data={"pw1": "twelve-chars!", "pw2": "twelve-chars!"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers.get("location") == "/identities?created=1" + assert vault.is_file() + assert "keymaster_session" in response.cookies diff --git a/keymaster/tests/test_vault_crypto.py b/keymaster/tests/test_vault_crypto.py new file mode 100644 index 0000000..3b216d1 --- /dev/null +++ b/keymaster/tests/test_vault_crypto.py @@ -0,0 +1,27 @@ +"""Vault envelope encrypt/decrypt.""" + +from __future__ import annotations + +import pytest + +from modulr_keymaster.vault_crypto import ( + VaultCryptoError, + decrypt_vault_payload, + encrypt_vault_payload, +) + + +def test_encrypt_decrypt_roundtrip() -> None: + inner = {"profiles": []} + env = encrypt_vault_payload("twelve-chars!", inner) + assert env["vault_version"] == 1 + assert env["kdf"] == "argon2id" + out = decrypt_vault_payload("twelve-chars!", env) + assert out == inner + + +def test_wrong_passphrase() -> None: + inner = {"profiles": []} + env = encrypt_vault_payload("twelve-chars!", inner) + with pytest.raises(VaultCryptoError): + decrypt_vault_payload("twelve-wrong!", env)