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.
{% 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 %}
+ {{ error }}
+ {% endif %}
+
+
+{% 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