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
44 changes: 44 additions & 0 deletions keymaster/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Keymaster (local)

Local **Ed25519** identity tool: generate **named** key profiles, **password-protect** the vault, browse via a **loopback web UI**.

**Full plan:** [`plan/keymaster_local_wallet.md`](../plan/keymaster_local_wallet.md)

**Design context:** [`docs/identity_encryption_and_org_policy.md`](../docs/identity_encryption_and_org_policy.md)

## 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.

## Run (development)

From the **repository root**, with your Modulr.Core venv activated:

```powershell
pip install -e ./keymaster
modulr-keymaster --reload
```

Or from `keymaster/`:

```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`).

- **`--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

| Path | Screen |
|------|--------|
| `/unlock` | Unlock vault |
| `/setup` | First-run / create vault |
| `/identities` | Dashboard (mock profiles) |
| `/identities/{id}` | Profile detail + copy public key |

Static theme files live under `src/modulr_keymaster/static/`; templates under `templates/`.
35 changes: 35 additions & 0 deletions keymaster/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"

[project]
name = "modulr-keymaster"
version = "0.1.0"
description = "Keymaster — local Ed25519 identity vault (loopback UI)"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "BSL-1.1" }
authors = [{ name = "Modulr" }]
dependencies = [
"fastapi>=0.115.0",
"jinja2>=3.1.0",
"uvicorn[standard]>=0.30.0",
]

[project.scripts]
modulr-keymaster = "modulr_keymaster.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/modulr_keymaster"]

[tool.hatch.build.targets.wheel.force-include]
"src/modulr_keymaster/static" = "modulr_keymaster/static"
"src/modulr_keymaster/templates" = "modulr_keymaster/templates"

[tool.ruff]
target-version = "py311"
line-length = 88
src = ["src"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
3 changes: 3 additions & 0 deletions keymaster/src/modulr_keymaster/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Local Keymaster loopback UI and (future) encrypted vault."""

__version__ = "0.1.0"
115 changes: 115 additions & 0 deletions keymaster/src/modulr_keymaster/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""FastAPI app: themed static UI for Keymaster (mock data until vault exists)."""

from __future__ import annotations

from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

_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 create_app() -> FastAPI:
app = FastAPI(title="Keymaster", version="0.1.0", docs_url=None, redoc_url=None)
templates = Jinja2Templates(directory=str(_PKG_DIR / "templates"))
app.mount(
"/static",
StaticFiles(directory=str(_PKG_DIR / "static")),
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)
async def root() -> RedirectResponse:
return RedirectResponse(url="/unlock", status_code=302)

@app.get("/unlock", response_class=HTMLResponse)
async def unlock(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request,
"unlock.html",
ctx(request, page_title="Unlock vault", nav_section="unlock"),
)

@app.get("/setup", response_class=HTMLResponse)
async def setup(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request,
"setup.html",
ctx(request, page_title="Create vault", nav_section="setup"),
)

@app.get("/identities", response_class=HTMLResponse)
async def identities(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request,
"dashboard.html",
ctx(
request,
page_title="Identities",
profiles=MOCK_PROFILES,
nav_section="identities",
),
)

@app.get("/identities/{profile_id}", response_class=HTMLResponse)
async def identity_detail(request: Request, profile_id: str) -> HTMLResponse:
profile = _profile_by_id(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_detail.html",
ctx(
request,
page_title=profile["display_name"],
profile=profile,
nav_section="detail",
),
)

return app
50 changes: 50 additions & 0 deletions keymaster/src/modulr_keymaster/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""CLI: run Keymaster loopback server."""

from __future__ import annotations

import argparse
import sys

import uvicorn

from modulr_keymaster.app import create_app


def main() -> None:
parser = argparse.ArgumentParser(description="Keymaster local web UI")
parser.add_argument(
"--host",
default="127.0.0.1",
help="Bind address (default: 127.0.0.1)",
)
parser.add_argument(
"--port",
type=int,
default=8765,
help="Port (default: 8765)",
)
parser.add_argument(
"--reload",
action="store_true",
help="Dev auto-reload (watch package files)",
)
args = parser.parse_args()

if args.host in ("0.0.0.0", "::"):
print(
"Keymaster: binding to all interfaces is discouraged; "
"this tool is intended for loopback only.",
file=sys.stderr,
)

uvicorn.run(
"modulr_keymaster.app:create_app",
factory=True,
host=args.host,
port=args.port,
reload=args.reload,
)


if __name__ == "__main__":
main()
Loading
Loading