feat: build secure keychain monorepo

This commit is contained in:
Keychain Builder 2026-09-09 19:53:50 +00:00
commit a14a5d2471
41 changed files with 5725 additions and 0 deletions

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
# Frontend
VITE_API_URL=http://localhost:8000/api
# Keep true for the visual demo. Set false to use the FastAPI account/vault API.
VITE_DEMO_MODE=true
# API
KEYCHAIN_ENV=development
KEYCHAIN_DB_PATH=apps/api/data/keychain.db
KEYCHAIN_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
KEYCHAIN_COOKIE_SECURE=false
KEYCHAIN_SESSION_TTL_SECONDS=1209600

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
node_modules/
dist/
*.local
.env
.env.*
!.env.example
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
apps/api/data/
coverage/
.DS_Store
*.db
*.sqlite3
*.sqlite3-journal

56
README.md Normal file
View file

@ -0,0 +1,56 @@
# Keychain
Полированный MVP менеджера паролей в стиле системного Keychain: Vue 3 + TypeScript на клиенте, FastAPI + SQLite для API и Manifest V3 расширение для Chromium/Firefox-подобных браузеров.
Интерфейс самостоятельный и вдохновлён спокойной компоновкой Apple Keychain — это не копия ассетов Apple и не официальный продукт Apple.
## Архитектура безопасности
- Пароль аккаунта и мастер-пароль разделены. Пароль аккаунта нужен только API для аутентификации.
- Vault шифруется в браузере: Argon2id выводит ключ из мастер-пароля, данные шифруются AES-256-GCM с новым nonce на каждое сохранение.
- API хранит только versioned envelope (`salt`, KDF-параметры, nonce, ciphertext, revision) и не получает мастер-пароль.
- Сессии — случайные opaque-токены; на сервере хранится только SHA-256 хэш токена. Cookie сессии HttpOnly/SameSite, CSRF-токен отдельный.
- Расширение не заполняет поля автоматически и не отправляет форму. Перед fill проверяется точное совпадение origin.
- После перезапуска service worker расширение снова заблокировано; расшифрованные записи живут только в памяти worker-а.
Это **не security-аудированный production password manager**. Перед реальным использованием нужны HTTPS, PostgreSQL, rate limiting на edge, CSP/Trusted Types, аудит supply chain и криптографии, резервное копирование, threat modeling и независимый security review. Сброс пароля аккаунта не восстанавливает vault.
## Запуск
```bash
npm install
cp .env.example apps/web/.env.local
# terminal 1
python3 -m venv .venv
. .venv/bin/activate
pip install -r apps/api/requirements.txt
KEYCHAIN_ALLOWED_ORIGINS=http://localhost:5173 \
python3 -m uvicorn app.main:app --reload --app-dir apps/api --port 8000
# terminal 2
npm run dev
```
По умолчанию web-приложение запускается в безопасном `demo mode`, чтобы можно было посмотреть UI без API. Для реальных регистраций выставьте `VITE_DEMO_MODE=false` в `apps/web/.env.local`. Demo mode использует только синтетические записи, не реальные пароли.
Сборка:
```bash
npm run typecheck
npm run build
npm run api:test
```
Готовое расширение появляется в `apps/extension/dist`; его можно загрузить как unpacked extension в Chrome/Edge/Brave или импортировать в Firefox с учётом различий MV3.
## Структура
```text
apps/
api/ FastAPI, SQLite dev storage, auth/session/vault endpoints
web/ Vue 3 responsive vault UI
extension/ Manifest V3 popup, background worker and origin-checked content script
packages/
core/ shared types, API client and browser crypto envelope
```

18
SECURITY.md Normal file
View file

@ -0,0 +1,18 @@
# Security notes
## Scope
The project is an educational MVP. It is designed to demonstrate a client-side encrypted vault and a cautious browser-extension flow. It has not undergone a professional security audit.
## Non-negotiable production work
1. Serve the web app and API only over HTTPS with HSTS.
2. Move the dev SQLite store to PostgreSQL, encrypt backups, and test restore procedures.
3. Put authentication rate limiting and account lockout controls at a trusted edge, with alerting.
4. Pin and continuously audit JavaScript/Python dependencies and the extension release pipeline.
5. Add CSP with nonces/Trusted Types, dependency review, XSS testing, CSRF/origin tests, and a threat model.
6. Review memory lifetime, key derivation parameters, recovery/export, multi-device sync and concurrent revisions with a cryptographer.
## Product guarantees
The server never needs the master password to read or write a vault envelope. This does not protect against a malicious or compromised server delivering modified JavaScript to a user: a password manager must treat its application delivery path as a high-trust boundary. The extension only fills after a user action and checks exact origins, but a compromised page can read credentials once they are placed in its DOM.

11
apps/api/Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN mkdir -p /data
ENV KEYCHAIN_DB_PATH=/data/keychain.db
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

1
apps/api/app/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Keychain API package."""

364
apps/api/app/main.py Normal file
View file

@ -0,0 +1,364 @@
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import secrets
import sqlite3
import time
from collections import defaultdict, deque
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
from uuid import uuid4
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, field_validator
ROOT = Path(__file__).resolve().parents[1]
DB_PATH = Path(os.getenv("KEYCHAIN_DB_PATH", str(ROOT / "data" / "keychain.db")))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
SESSION_TTL = int(os.getenv("KEYCHAIN_SESSION_TTL_SECONDS", "1209600"))
COOKIE_SECURE = os.getenv("KEYCHAIN_COOKIE_SECURE", "false").lower() == "true"
COOKIE_SAMESITE = "lax"
SESSION_COOKIE = "kc_session"
CSRF_COOKIE = "kc_csrf"
configured_origins = [
origin.strip()
for origin in os.getenv(
"KEYCHAIN_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
).split(",")
if origin.strip()
]
password_hasher = PasswordHasher()
email_pattern = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
auth_attempts: dict[str, deque[float]] = defaultdict(deque)
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def iso_now() -> str:
return utc_now().isoformat()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@contextmanager
def db() -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(DB_PATH, timeout=10)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
try:
yield connection
connection.commit()
finally:
connection.close()
def init_db() -> None:
with db() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vaults (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
envelope TEXT,
revision INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
csrf_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_token_idx ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
"""
)
init_db()
class AuthPayload(BaseModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=8, max_length=128)
display_name: str | None = Field(default=None, max_length=80)
@field_validator("email")
@classmethod
def normalize_email(cls, value: str) -> str:
normalized = value.strip().lower()
if not email_pattern.fullmatch(normalized):
raise ValueError("Enter a valid email address")
return normalized
@field_validator("password")
@classmethod
def reject_control_chars(cls, value: str) -> str:
if any(ord(character) < 32 for character in value):
raise ValueError("Password contains unsupported control characters")
return value
class VaultPayload(BaseModel):
envelope: dict[str, Any] = Field(min_length=1)
revision: int = Field(ge=0)
@field_validator("envelope")
@classmethod
def validate_size(cls, value: dict[str, Any]) -> dict[str, Any]:
serialized = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
if len(serialized.encode("utf-8")) > 8 * 1024 * 1024:
raise ValueError("Vault envelope is too large")
return value
def public_user(row: sqlite3.Row) -> dict[str, str]:
return {
"id": row["id"],
"email": row["email"],
"display_name": row["display_name"],
}
def enforce_auth_rate_limit(request: Request) -> None:
address = request.client.host if request.client else "unknown"
now = time.monotonic()
attempts = auth_attempts[address]
while attempts and attempts[0] < now - 60:
attempts.popleft()
if len(attempts) >= 12:
raise HTTPException(status_code=429, detail="Too many attempts. Try again in a minute.")
attempts.append(now)
def issue_session(response: Response, user_id: str) -> str:
raw_session = secrets.token_urlsafe(32)
raw_csrf = secrets.token_urlsafe(24)
now = utc_now()
expires = now + timedelta(seconds=SESSION_TTL)
with db() as connection:
connection.execute(
"INSERT INTO sessions (id, user_id, token_hash, csrf_hash, created_at, expires_at, last_seen_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
uuid4().hex,
user_id,
hash_token(raw_session),
hash_token(raw_csrf),
now.isoformat(),
expires.isoformat(),
now.isoformat(),
),
)
response.set_cookie(
SESSION_COOKIE,
raw_session,
max_age=SESSION_TTL,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
response.set_cookie(
CSRF_COOKIE,
raw_csrf,
max_age=SESSION_TTL,
httponly=False,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
return raw_csrf
def current_session(request: Request) -> sqlite3.Row:
raw_token = request.cookies.get(SESSION_COOKIE)
if not raw_token:
raise HTTPException(status_code=401, detail="Authentication required")
now = utc_now()
with db() as connection:
session = connection.execute(
"SELECT * FROM sessions WHERE token_hash = ?", (hash_token(raw_token),)
).fetchone()
if session is None:
raise HTTPException(status_code=401, detail="Session is invalid")
if datetime.fromisoformat(session["expires_at"]) <= now:
connection.execute("DELETE FROM sessions WHERE id = ?", (session["id"],))
raise HTTPException(status_code=401, detail="Session has expired")
connection.execute(
"UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now.isoformat(), session["id"])
)
return session
def require_csrf(request: Request, session: sqlite3.Row) -> None:
header_token = request.headers.get("x-csrf-token", "")
cookie_token = request.cookies.get(CSRF_COOKIE, "")
if not header_token or not hmac.compare_digest(header_token, cookie_token):
raise HTTPException(status_code=403, detail="CSRF validation failed")
if not hmac.compare_digest(hash_token(header_token), session["csrf_hash"]):
raise HTTPException(status_code=403, detail="CSRF token is invalid")
def user_for_session(session: sqlite3.Row) -> sqlite3.Row:
with db() as connection:
user = connection.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if user is None:
raise HTTPException(status_code=401, detail="Account no longer exists")
return user
app = FastAPI(title="Keychain API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=configured_origins,
allow_origin_regex=r"^chrome-extension://[a-zA-Z0-9]+$",
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
allow_headers=["Content-Type", "X-CSRF-Token"],
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/auth/register", status_code=status.HTTP_201_CREATED)
def register(payload: AuthPayload, request: Request, response: Response) -> dict[str, Any]:
enforce_auth_rate_limit(request)
user_id = uuid4().hex
display_name = (payload.display_name or payload.email.split("@", 1)[0]).strip()[:80]
now = iso_now()
try:
with db() as connection:
connection.execute(
"INSERT INTO users (id, email, display_name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
(user_id, payload.email, display_name, password_hasher.hash(payload.password), now),
)
connection.execute(
"INSERT INTO vaults (user_id, envelope, revision, updated_at) VALUES (?, ?, 0, ?)",
(user_id, None, now),
)
except sqlite3.IntegrityError as error:
if "email" in str(error).lower():
raise HTTPException(status_code=409, detail="An account with this email already exists") from error
raise
csrf_token = issue_session(response, user_id)
with db() as connection:
user = connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return {"user": public_user(user), "csrf_token": csrf_token}
@app.post("/api/auth/login")
def login(payload: AuthPayload, request: Request, response: Response) -> dict[str, Any]:
enforce_auth_rate_limit(request)
with db() as connection:
user = connection.execute("SELECT * FROM users WHERE email = ?", (payload.email,)).fetchone()
if user is None:
raise HTTPException(status_code=401, detail="Email or password is incorrect")
try:
password_hasher.verify(user["password_hash"], payload.password)
except (VerifyMismatchError, VerificationError, InvalidHashError) as error:
raise HTTPException(status_code=401, detail="Email or password is incorrect") from error
if password_hasher.check_needs_rehash(user["password_hash"]):
with db() as connection:
connection.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(password_hasher.hash(payload.password), user["id"]),
)
csrf_token = issue_session(response, user["id"])
return {"user": public_user(user), "csrf_token": csrf_token}
@app.get("/api/auth/me")
def me(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]:
return {"user": public_user(user_for_session(session))}
@app.post("/api/auth/logout")
def logout(request: Request, response: Response, session: sqlite3.Row = Depends(current_session)) -> dict[str, str]:
require_csrf(request, session)
with db() as connection:
connection.execute("DELETE FROM sessions WHERE id = ?", (session["id"],))
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
return {"status": "signed_out"}
@app.get("/api/vault")
def get_vault(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]:
user = user_for_session(session)
with db() as connection:
vault = connection.execute("SELECT * FROM vaults WHERE user_id = ?", (user["id"],)).fetchone()
if vault is None:
raise HTTPException(status_code=404, detail="Vault not found")
return {
"envelope": json.loads(vault["envelope"]) if vault["envelope"] else None,
"revision": vault["revision"],
"updated_at": vault["updated_at"],
}
@app.put("/api/vault")
def put_vault(
payload: VaultPayload,
request: Request,
session: sqlite3.Row = Depends(current_session),
) -> dict[str, Any]:
require_csrf(request, session)
with db() as connection:
vault = connection.execute(
"SELECT revision FROM vaults WHERE user_id = ?", (session["user_id"],)
).fetchone()
if vault is None:
raise HTTPException(status_code=404, detail="Vault not found")
if vault["revision"] != payload.revision:
raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving")
new_revision = payload.revision + 1
updated_at = iso_now()
connection.execute(
"UPDATE vaults SET envelope = ?, revision = ?, updated_at = ? WHERE user_id = ?",
(
json.dumps(payload.envelope, separators=(",", ":"), ensure_ascii=False),
new_revision,
updated_at,
session["user_id"],
),
)
return {"revision": new_revision, "updated_at": updated_at}

View file

@ -0,0 +1,3 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30,<1.0
argon2-cffi>=23.1,<26.0

View file

View file

@ -0,0 +1,14 @@
import unittest
from pathlib import Path
class ApiContractTest(unittest.TestCase):
"""Smoke tests are kept opt-in so importing the app never changes a user's dev DB."""
def test_contract_is_documented(self):
readme = Path(__file__).parents[3] / "README.md"
self.assertIn("шифруется в браузере", readme.read_text())
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,37 @@
{
"manifest_version": 3,
"name": "Keychain — secure autofill",
"version": "0.1.0",
"description": "A user-triggered, origin-checked companion for your Keychain vault.",
"action": {
"default_title": "Open Keychain",
"default_popup": "popup.html"
},
"background": {
"service_worker": "assets/background.js",
"type": "module"
},
"options_page": "options.html",
"permissions": [
"activeTab",
"scripting",
"storage"
],
"host_permissions": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"content_scripts": [
{
"matches": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"js": ["assets/content.js"],
"run_at": "document_idle"
}
],
"icons": {}
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keychain settings</title>
</head>
<body>
<main id="options-app"></main>
<script type="module" src="/src/options.ts"></script>
</body>
</html>

View file

@ -0,0 +1,19 @@
{
"name": "@keychain/extension",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@keychain/core": "0.1.0"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"@types/node": "^22.10.5",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

12
apps/extension/popup.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keychain</title>
</head>
<body>
<div id="popup-app"></div>
<script type="module" src="/src/popup.ts"></script>
</body>
</html>

View file

@ -0,0 +1,75 @@
import type { VaultEntry } from "@keychain/core";
import { originFromUrl } from "./extension";
let unlockedEntries: VaultEntry[] = [];
function isVaultEntry(value: unknown): value is VaultEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<VaultEntry>;
return (
typeof entry.id === "string" &&
typeof entry.title === "string" &&
typeof entry.username === "string" &&
typeof entry.password === "string" &&
typeof entry.url === "string"
);
}
function matchingEntries(origin: string | null): VaultEntry[] {
if (!origin) return [];
return unlockedEntries.filter((entry) => originFromUrl(entry.url) === origin);
}
chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) => {
const payload = message as { type?: string; entries?: unknown; origin?: string | null; tabId?: number; entryId?: string };
(async () => {
if (payload.type === "STATUS") {
sendResponse({ unlocked: unlockedEntries.length > 0, count: unlockedEntries.length });
return;
}
if (payload.type === "SET_VAULT") {
const entries = Array.isArray(payload.entries) ? payload.entries.filter(isVaultEntry) : [];
// Keep the decrypted vault only in the worker's volatile memory. A worker restart locks it.
unlockedEntries = entries.slice(0, 500);
sendResponse({ ok: true, count: unlockedEntries.length });
return;
}
if (payload.type === "MATCHING_ENTRIES") {
sendResponse({ entries: matchingEntries(payload.origin ?? null) });
return;
}
if (payload.type === "LOCK") {
unlockedEntries = [];
sendResponse({ ok: true });
return;
}
if (payload.type === "FILL_ENTRY") {
const tabId = payload.tabId;
if (typeof tabId !== "number" || !Number.isInteger(tabId) || typeof payload.entryId !== "string") {
sendResponse({ ok: false, error: "Invalid fill request" });
return;
}
const tab = await chrome.tabs.get(tabId);
const pageOrigin = originFromUrl(tab.url);
const entry = unlockedEntries.find((candidate) => candidate.id === payload.entryId);
if (!entry || !pageOrigin || originFromUrl(entry.url) !== pageOrigin) {
sendResponse({ ok: false, error: "This login is not allowed on the current origin" });
return;
}
await chrome.tabs.sendMessage(tabId, { type: "FILL_CREDENTIAL", credential: entry });
sendResponse({ ok: true });
return;
}
sendResponse({ ok: false, error: "Unknown message" });
})().catch((error: unknown) => {
sendResponse({ ok: false, error: error instanceof Error ? error.message : "Extension error" });
});
return true;
});

View file

@ -0,0 +1,62 @@
import type { VaultEntry } from "@keychain/core";
import { originFromUrl } from "./extension";
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function visibleInputs(): HTMLInputElement[] {
return Array.from(document.querySelectorAll<HTMLInputElement>("input:not([type=hidden]):not([disabled])")).filter((input) => {
const style = window.getComputedStyle(input);
return style.display !== "none" && style.visibility !== "hidden";
});
}
function findUsernameInput(inputs: HTMLInputElement[]) {
return (
inputs.find((input) => /username|email|login|user/i.test(`${input.autocomplete} ${input.name} ${input.id}`)) ||
inputs.find((input) => input.type === "email") ||
inputs.find((input) => input.type === "text")
);
}
function showFillNotice(message: string) {
const notice = document.createElement("div");
notice.textContent = message;
Object.assign(notice.style, {
position: "fixed",
zIndex: "2147483647",
right: "18px",
bottom: "18px",
padding: "10px 13px",
color: "#fff",
background: "#202a40",
borderRadius: "10px",
boxShadow: "0 12px 30px rgba(0,0,0,.2)",
font: "600 12px -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
});
document.documentElement.appendChild(notice);
window.setTimeout(() => notice.remove(), 2600);
}
chrome.runtime.onMessage.addListener((message: { type?: string; credential?: VaultEntry }) => {
if (message.type !== "FILL_CREDENTIAL" || !message.credential) return;
const credential = message.credential;
if (originFromUrl(credential.url) !== location.origin) {
showFillNotice("Keychain blocked a cross-origin fill");
return;
}
const inputs = visibleInputs();
const passwordInput = inputs.find((input) => input.type === "password");
const usernameInput = findUsernameInput(inputs.filter((input) => input !== passwordInput));
if (!passwordInput && !usernameInput) {
showFillNotice("No login fields found on this page");
return;
}
if (usernameInput && credential.username) setInputValue(usernameInput, credential.username);
if (passwordInput) setInputValue(passwordInput, credential.password);
showFillNotice("Keychain filled the selected login");
});

View file

@ -0,0 +1,63 @@
:root {
color-scheme: light;
--page: #f4f6fb;
--surface: #fff;
--ink: #192338;
--soft: #4e5b73;
--muted: #8c97aa;
--line: #e4e8f0;
--blue: #526fe8;
--blue-soft: #eef1ff;
--green: #51a981;
}
* { box-sizing: border-box; }
body { min-width: 340px; margin: 0; color: var(--ink); background: var(--page); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 12px; }
button, input { font: inherit; }
button { cursor: pointer; }
.extension-shell { width: 360px; min-height: 470px; padding: 18px 17px 13px; background: radial-gradient(circle at 95% 0%, #e9edff 0, transparent 32%), var(--page); }
.extension-header, .extension-footer, .extension-page-context, .extension-brand, .match-card { display: flex; align-items: center; }
.extension-header { justify-content: space-between; margin-bottom: 24px; }
.extension-brand { gap: 8px; font-size: 14px; letter-spacing: -.04em; }
.extension-mark { display: grid; width: 27px; height: 27px; place-items: center; color: #fff; background: linear-gradient(145deg,#748df2,#5f5bdb); border-radius: 9px; box-shadow: 0 5px 12px #6874dc55; font-size: 18px; font-weight: 800; }
.extension-lock { color: var(--muted); font-size: 10px; font-weight: 700; }
.extension-lock--button { padding: 6px 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; }
.extension-page-context { gap: 6px; max-width: 100%; margin-bottom: 23px; color: var(--muted); font-size: 10px; }
.extension-page-context strong { overflow: hidden; color: var(--soft); text-overflow: ellipsis; white-space: nowrap; }
.context-dot { width: 6px; height: 6px; flex: 0 0 auto; background: var(--green); border-radius: 50%; box-shadow: 0 0 0 4px #51a9811c; }
.extension-title { margin-bottom: 24px; }
.extension-title--compact { margin-bottom: 17px; }
.extension-kicker { display: block; margin-bottom: 5px; color: var(--blue); font-size: 9px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
.extension-title h1, .options-card h1 { margin: 0 0 7px; font-size: 23px; letter-spacing: -.065em; }
.extension-title p, .options-card p { max-width: 315px; margin: 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.extension-form { display: flex; flex-direction: column; gap: 13px; }
.extension-form label { display: flex; flex-direction: column; gap: 6px; color: var(--soft); font-size: 10px; font-weight: 700; }
.extension-form input { min-height: 39px; padding: 0 11px; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 9px; outline: none; font-size: 11px; }
.extension-form input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px #526fe81a; }
.extension-primary { display: flex; align-items: center; justify-content: space-between; min-height: 41px; padding: 0 13px; color: #fff; background: var(--blue); border: 0; border-radius: 9px; box-shadow: 0 8px 16px #526fe82b; font-size: 11px; font-weight: 700; }
.extension-primary:disabled { cursor: wait; opacity: .65; }
.extension-primary span { font-size: 16px; font-weight: 400; }
.extension-error { padding: 9px 10px; color: #b55262; background: #fff0f2; border: 1px solid #ffd7dc; border-radius: 8px; font-size: 10px; line-height: 1.4; }
.extension-matches { display: flex; flex-direction: column; gap: 7px; }
.match-card { width: 100%; gap: 9px; padding: 9px; color: var(--soft); text-align: left; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; transition: 140ms ease; }
.match-card:hover { border-color: #bdc8fb; box-shadow: 0 5px 15px #526fe814; transform: translateY(-1px); }
.match-logo { display: grid; width: 31px; height: 31px; flex: 0 0 auto; place-items: center; color: #fff; background: linear-gradient(145deg,#8299ee,#5b70d3); border-radius: 9px; font-size: 9px; font-weight: 800; }
.match-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.match-copy strong, .match-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.match-copy strong { color: var(--soft); font-size: 11px; }
.match-copy span { color: var(--muted); font-size: 10px; }
.match-arrow { color: var(--blue); font-size: 16px; }
.no-match { display: flex; align-items: center; padding: 18px 11px; flex-direction: column; gap: 5px; color: var(--muted); text-align: center; background: var(--surface); border: 1px dashed var(--line); border-radius: 11px; }
.no-match span { color: var(--blue); font-size: 22px; }
.no-match strong { color: var(--soft); font-size: 11px; }
.no-match small { max-width: 230px; font-size: 10px; line-height: 1.4; }
.extension-footer { justify-content: space-between; margin-top: 24px; padding-top: 12px; color: var(--muted); border-top: 1px solid var(--line); font-size: 9px; }
.extension-footer button { padding: 0; color: var(--blue); background: transparent; border: 0; font-size: 9px; font-weight: 700; }
.options-card { width: min(100% - 34px, 470px); margin: 70px auto; padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: 17px; box-shadow: 0 18px 50px #23335714; }
.options-card .extension-brand { margin-bottom: 41px; }
.options-card h1 { margin-top: 8px; }
.options-card .extension-form { margin-top: 24px; }
.settings-status { min-height: 17px; margin-top: 12px; font-size: 10px; }
.settings-status--success { color: var(--green); }
.settings-status--error { color: #bd5667; }
.options-note { display: block; margin-top: 16px; color: var(--muted); font-size: 10px; }

View file

@ -0,0 +1,26 @@
export interface ExtensionSettings {
apiUrl: string;
}
export const DEFAULT_SETTINGS: ExtensionSettings = {
apiUrl: "http://localhost:8000/api",
};
export async function getSettings(): Promise<ExtensionSettings> {
const stored = await chrome.storage.sync.get(DEFAULT_SETTINGS);
return {
apiUrl: String(stored.apiUrl || DEFAULT_SETTINGS.apiUrl).replace(/\/$/, ""),
};
}
export function originFromUrl(value: string | undefined): string | null {
if (!value) return null;
try {
const url = new URL(value);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
if (url.protocol === "http:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") return null;
return url.origin;
} catch {
return null;
}
}

View file

@ -0,0 +1,37 @@
import { DEFAULT_SETTINGS, getSettings } from "./extension";
import "./extension.css";
const rootElement = document.querySelector<HTMLElement>("#options-app");
if (!rootElement) throw new Error("Options root is missing");
const root = rootElement;
async function start() {
const settings = await getSettings();
root.innerHTML = `
<section class="options-card">
<div class="extension-brand"><span class="extension-mark"></span><strong>Keychain</strong></div>
<span class="extension-kicker">Extension settings</span><h1>Connect your vault</h1>
<p>Use the same API origin as the web app. The extension stores only this endpoint and never stores your master password.</p>
<form id="settings-form" class="extension-form"><label>API URL<input id="api-url" type="url" value="${settings.apiUrl}" required /></label><button class="extension-primary" type="submit">Save settings <span></span></button></form>
<div id="settings-status" class="settings-status"></div>
<small class="options-note">Default: ${DEFAULT_SETTINGS.apiUrl}. For production, use an HTTPS API.</small>
</section>`;
document.querySelector<HTMLFormElement>("#settings-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.querySelector<HTMLInputElement>("#api-url");
const status = document.querySelector<HTMLDivElement>("#settings-status");
if (!input || !status) return;
try {
const parsed = new URL(input.value);
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") throw new Error("Use HTTPS outside local development");
await chrome.storage.sync.set({ apiUrl: input.value.replace(/\/$/, "") });
status.textContent = "Settings saved";
status.className = "settings-status settings-status--success";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "Unable to save";
status.className = "settings-status settings-status--error";
}
});
}
void start();

122
apps/extension/src/popup.ts Normal file
View file

@ -0,0 +1,122 @@
import {
decryptVault,
KeychainApiClient,
type User,
type VaultEntry,
} from "@keychain/core";
import { getSettings, originFromUrl } from "./extension";
import "./extension.css";
const rootElement = document.querySelector<HTMLDivElement>("#popup-app");
if (!rootElement) throw new Error("Popup root is missing");
const root = rootElement;
let activeTabId: number | undefined;
let activeOrigin: string | null = null;
let unlocked = false;
let entries: VaultEntry[] = [];
let busy = false;
let errorMessage = "";
function escapeHtml(value: string) {
return value.replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character] || character);
}
function initials(title: string) {
const words = title.trim().split(/\s+/).filter(Boolean);
return escapeHtml((words.length > 1 ? words[0][0] + words[1][0] : title.slice(0, 2)).toUpperCase());
}
function render() {
const originLabel = activeOrigin ? escapeHtml(activeOrigin.replace(/^https?:\/\//, "")) : "This page";
if (!unlocked) {
root.innerHTML = `
<div class="extension-shell">
<header class="extension-header"><div class="extension-brand"><span class="extension-mark"></span><strong>Keychain</strong></div><span class="extension-lock">Locked</span></header>
<div class="extension-page-context"><span class="context-dot"></span><span>Fill for</span><strong>${originLabel}</strong></div>
<div class="extension-title"><span class="extension-kicker">Private by design</span><h1>Unlock to fill</h1><p>Your master password stays in this browser and is never sent to the server.</p></div>
<form id="unlock-extension" class="extension-form">
<label>Email<input name="email" type="email" placeholder="you@example.com" autocomplete="username" required /></label>
<label>Account password<input name="accountPassword" type="password" placeholder="Your account password" autocomplete="current-password" required /></label>
<label>Master password<input name="masterPassword" type="password" placeholder="Unlock your vault" autocomplete="new-password" required /></label>
${errorMessage ? `<div class="extension-error">${escapeHtml(errorMessage)}</div>` : ""}
<button class="extension-primary" type="submit" ${busy ? "disabled" : ""}>${busy ? "Unlocking…" : "Unlock vault <span>→</span>"}</button>
</form>
<footer class="extension-footer"><span>Encrypted locally</span><button id="open-options" type="button">Settings</button></footer>
</div>`;
document.querySelector<HTMLFormElement>("#unlock-extension")?.addEventListener("submit", unlock);
document.querySelector<HTMLButtonElement>("#open-options")?.addEventListener("click", () => chrome.runtime.openOptionsPage());
return;
}
const matches = entries.filter((entry) => originFromUrl(entry.url) === activeOrigin);
root.innerHTML = `
<div class="extension-shell">
<header class="extension-header"><div class="extension-brand"><span class="extension-mark"></span><strong>Keychain</strong></div><button id="lock-extension" class="extension-lock extension-lock--button" type="button">Lock</button></header>
<div class="extension-page-context"><span class="context-dot"></span><span>Matches for</span><strong>${originLabel}</strong></div>
<div class="extension-title extension-title--compact"><span class="extension-kicker">Ready to fill</span><h1>${matches.length ? `${matches.length} login${matches.length === 1 ? "" : "s"} found` : "No login found"}</h1><p>${matches.length ? "Choose a login to fill. Keychain will not submit the form." : "Add a password with this website URL in your vault to use it here."}</p></div>
<div class="extension-matches">${matches.map((entry) => `<button class="match-card" data-entry-id="${escapeHtml(entry.id)}" type="button"><span class="match-logo">${initials(entry.title)}</span><span class="match-copy"><strong>${escapeHtml(entry.title)}</strong><span>${escapeHtml(entry.username || "No username")}</span></span><span class="match-arrow">→</span></button>`).join("") || `<div class="no-match"><span>⌕</span><strong>Nothing for this origin yet</strong><small>Exact origin matching protects you from accidental fills.</small></div>`}</div>
<footer class="extension-footer"><span>Unlocked in memory only</span><button id="open-options" type="button">Settings</button></footer>
</div>`;
document.querySelectorAll<HTMLButtonElement>("[data-entry-id]").forEach((button) => button.addEventListener("click", () => fill(button.dataset.entryId || "")));
document.querySelector<HTMLButtonElement>("#lock-extension")?.addEventListener("click", lock);
document.querySelector<HTMLButtonElement>("#open-options")?.addEventListener("click", () => chrome.runtime.openOptionsPage());
}
async function unlock(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form);
busy = true;
errorMessage = "";
render();
try {
const settings = await getSettings();
const client = new KeychainApiClient(settings.apiUrl);
const user: User = await client.login(String(data.get("email")), String(data.get("accountPassword")));
const response = await client.getVault();
if (!response.envelope) throw new Error("This account has no encrypted vault yet");
const document = await decryptVault(response.envelope, String(data.get("masterPassword")), user.id);
entries = document.entries;
await chrome.runtime.sendMessage({ type: "SET_VAULT", entries });
unlocked = true;
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Unable to unlock";
} finally {
busy = false;
render();
}
}
async function fill(entryId: string) {
if (!activeTabId) return;
const response = await chrome.runtime.sendMessage({ type: "FILL_ENTRY", entryId, tabId: activeTabId });
if (!response?.ok) {
errorMessage = response?.error || "Unable to fill this login";
render();
} else {
window.setTimeout(() => window.close(), 180);
}
}
async function lock() {
entries = [];
unlocked = false;
await chrome.runtime.sendMessage({ type: "LOCK" });
render();
}
async function start() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
activeTabId = tab?.id;
activeOrigin = originFromUrl(tab?.url);
const status = await chrome.runtime.sendMessage({ type: "STATUS" });
unlocked = Boolean(status?.unlocked);
if (unlocked && activeOrigin) {
const result = await chrome.runtime.sendMessage({ type: "MATCHING_ENTRIES", origin: activeOrigin });
entries = Array.isArray(result?.entries) ? result.entries : [];
}
render();
}
void start();

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["chrome"]
},
"include": ["src", "vite.config.ts"]
}

View file

@ -0,0 +1,34 @@
import { copyFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const extensionRoot = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
popup: resolve(extensionRoot, "popup.html"),
options: resolve(extensionRoot, "options.html"),
background: resolve(extensionRoot, "src/background.ts"),
content: resolve(extensionRoot, "src/content.ts"),
},
output: {
entryFileNames: "assets/[name].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name][extname]",
},
},
},
plugins: [
{
name: "copy-manifest",
closeBundle() {
copyFileSync(resolve(extensionRoot, "manifest.json"), resolve(extensionRoot, "dist/manifest.json"));
},
},
],
});

14
apps/web/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f5f7fb" />
<meta name="description" content="Keychain — a calm, private password vault." />
<title>Keychain — your private vault</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
apps/web/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@keychain/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@keychain/core": "0.1.0",
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

228
apps/web/src/App.vue Normal file
View file

@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import {
decryptVault,
encryptVault,
KeychainApiClient,
type User,
type VaultDocument,
} from "@keychain/core";
import UnlockView from "./views/UnlockView.vue";
import VaultView from "./views/VaultView.vue";
type Theme = "light" | "dark";
interface UnlockPayload {
mode: "sign-in" | "create";
email: string;
accountPassword: string;
masterPassword: string;
displayName?: string;
}
const isDemoMode = import.meta.env.VITE_DEMO_MODE !== "false";
const api = new KeychainApiClient(import.meta.env.VITE_API_URL || "http://localhost:8000/api");
const screen = ref<"unlock" | "vault">("unlock");
const busy = ref(false);
const errorMessage = ref("");
const user = ref<User | null>(null);
const vault = ref<VaultDocument | null>(null);
const revision = ref(0);
const masterPassword = ref("");
const theme = ref<Theme>((localStorage.getItem("keychain-theme") as Theme) || "light");
const demoUser: User = {
id: "demo-local-user",
email: "alex@keychain.local",
display_name: "Alex Morgan",
};
const demoEntries: VaultDocument = {
version: 1,
entries: [
{
id: "icloud",
title: "iCloud",
username: "alex.morgan@icloud.com",
password: "demo-icloud-2025",
url: "https://icloud.com",
category: "login",
favorite: true,
notes: "Personal Apple account",
createdAt: "2025-02-14T10:00:00.000Z",
updatedAt: "2025-08-21T09:30:00.000Z",
},
{
id: "github",
title: "GitHub",
username: "alex-morgan",
password: "demo-github-2025",
url: "https://github.com",
category: "login",
favorite: true,
notes: "Work repositories",
createdAt: "2025-03-02T13:20:00.000Z",
updatedAt: "2025-08-18T16:12:00.000Z",
},
{
id: "notion",
title: "Notion",
username: "alex@northstar.studio",
password: "demo-notion-2025",
url: "https://www.notion.so",
category: "login",
createdAt: "2025-05-10T08:15:00.000Z",
updatedAt: "2025-08-11T11:04:00.000Z",
},
{
id: "linear",
title: "Linear",
username: "alex@northstar.studio",
password: "demo-linear-2025",
url: "https://linear.app",
category: "login",
favorite: true,
createdAt: "2025-05-12T12:10:00.000Z",
updatedAt: "2025-08-08T10:21:00.000Z",
},
{
id: "figma",
title: "Figma",
username: "alex@northstar.studio",
password: "demo-figma-2025",
url: "https://www.figma.com",
category: "login",
createdAt: "2025-05-21T14:40:00.000Z",
updatedAt: "2025-07-29T18:42:00.000Z",
},
{
id: "amex",
title: "Amex Platinum",
username: "•••• 0042",
password: "demo-card-pin",
url: "https://www.americanexpress.com",
category: "card",
favorite: true,
notes: "Renewal in November",
createdAt: "2025-01-09T09:00:00.000Z",
updatedAt: "2025-07-18T13:05:00.000Z",
},
{
id: "wifi",
title: "Home Wi-Fi",
username: "northstar-home",
password: "demo-wifi-2025",
url: "",
category: "note",
createdAt: "2025-01-04T17:00:00.000Z",
updatedAt: "2025-06-02T20:15:00.000Z",
},
{
id: "passport",
title: "Passport",
username: "Alex Morgan",
password: "demo-passport-note",
url: "",
category: "identity",
notes: "Expires 2032",
createdAt: "2025-01-05T17:00:00.000Z",
updatedAt: "2025-04-02T20:15:00.000Z",
},
],
};
const themeClass = computed(() => `theme-${theme.value}`);
onMounted(() => {
document.documentElement.dataset.theme = theme.value;
});
function changeTheme(nextTheme: Theme) {
theme.value = nextTheme;
localStorage.setItem("keychain-theme", nextTheme);
document.documentElement.dataset.theme = nextTheme;
}
async function unlock(payload: UnlockPayload) {
errorMessage.value = "";
busy.value = true;
try {
if (isDemoMode) {
user.value = demoUser;
vault.value = structuredClone(demoEntries);
revision.value = 0;
} else {
user.value =
payload.mode === "create"
? await api.register(payload.email, payload.accountPassword, payload.displayName)
: await api.login(payload.email, payload.accountPassword);
const remoteVault = await api.getVault();
revision.value = remoteVault.revision;
if (remoteVault.envelope) {
vault.value = await decryptVault(remoteVault.envelope, payload.masterPassword, user.value.id);
} else {
vault.value = { version: 1, entries: [] };
const envelope = await encryptVault(vault.value, payload.masterPassword, user.value.id);
const saved = await api.saveVault(envelope, remoteVault.revision);
revision.value = saved.revision;
}
}
masterPassword.value = payload.masterPassword;
screen.value = "vault";
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Unable to unlock your vault";
user.value = null;
vault.value = null;
} finally {
busy.value = false;
}
}
async function saveVault(document: VaultDocument) {
vault.value = document;
if (isDemoMode || !user.value) return;
const envelope = await encryptVault(document, masterPassword.value, user.value.id);
const saved = await api.saveVault(envelope, revision.value);
revision.value = saved.revision;
}
async function lockVault(signOut = false) {
if (signOut && !isDemoMode) {
try {
await api.logout();
} catch {
// A local lock is safer than keeping secrets in memory if the session expired.
}
}
masterPassword.value = "";
vault.value = null;
user.value = null;
screen.value = "unlock";
errorMessage.value = "";
}
</script>
<template>
<div class="app-root" :class="themeClass">
<UnlockView
v-if="screen === 'unlock'"
:busy="busy"
:demo-mode="isDemoMode"
:error-message="errorMessage"
:theme="theme"
@submit="unlock"
@theme-change="changeTheme"
/>
<VaultView
v-else-if="user && vault"
:demo-mode="isDemoMode"
:user="user"
:vault="vault"
:theme="theme"
@lock="lockVault(false)"
@sign-out="lockVault(true)"
@save="saveVault"
@theme-change="changeTheme"
/>
</div>
</template>

5
apps/web/src/main.ts Normal file
View file

@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");

2034
apps/web/src/styles.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,198 @@
<script setup lang="ts">
import { ref } from "vue";
import { ArrowRight, Eye, EyeOff, KeyRound, LockKeyhole, ShieldCheck, Sun, Moon } from "lucide-vue-next";
type Theme = "light" | "dark";
interface Props {
busy: boolean;
demoMode: boolean;
errorMessage: string;
theme: Theme;
}
interface UnlockPayload {
mode: "sign-in" | "create";
email: string;
accountPassword: string;
masterPassword: string;
displayName?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submit: [payload: UnlockPayload];
"theme-change": [theme: Theme];
}>();
const mode = ref<"sign-in" | "create">("sign-in");
const email = ref("");
const displayName = ref("");
const accountPassword = ref("");
const masterPassword = ref("");
const showAccountPassword = ref(false);
const showMasterPassword = ref(false);
function submit() {
emit("submit", {
mode: mode.value,
email: email.value,
accountPassword: accountPassword.value,
masterPassword: masterPassword.value,
displayName: displayName.value || undefined,
});
}
function fillPreview() {
email.value = "alex@keychain.local";
accountPassword.value = "preview-account";
masterPassword.value = "preview-master";
submit();
}
</script>
<template>
<main class="unlock-page">
<header class="unlock-nav shell-width">
<div class="brand-lockup">
<span class="brand-mark brand-mark--small"><KeyRound :size="17" :stroke-width="2.4" /></span>
<span class="brand-wordmark">Keychain</span>
</div>
<div class="unlock-nav-actions">
<span class="status-pill"><span class="status-dot"></span> Private by design</span>
<button
class="icon-button icon-button--quiet"
:aria-label="theme === 'light' ? 'Use dark theme' : 'Use light theme'"
@click="emit('theme-change', theme === 'light' ? 'dark' : 'light')"
>
<Moon v-if="theme === 'light'" :size="17" />
<Sun v-else :size="17" />
</button>
</div>
</header>
<section class="unlock-layout shell-width">
<div class="unlock-intro">
<div class="eyebrow"><span class="eyebrow-line"></span> Your private space</div>
<h1>Everything important,<br /><em>right where you left it.</em></h1>
<p class="unlock-lede">
A quiet home for your passwords, cards, and notes. Designed to feel familiar, built to keep your private life private.
</p>
<div class="trust-card">
<div class="trust-orbit trust-orbit--outer"></div>
<div class="trust-orbit trust-orbit--inner"></div>
<div class="trust-icon"><ShieldCheck :size="25" :stroke-width="1.8" /></div>
<div class="trust-copy">
<strong>Encrypted before it leaves your device</strong>
<span>Your master password is never sent to the server.</span>
</div>
<div class="trust-check"><LockKeyhole :size="15" /></div>
</div>
<div class="unlock-sparkles" aria-hidden="true">
<span class="sparkle sparkle--one"></span>
<span class="sparkle sparkle--two">·</span>
<span class="sparkle sparkle--three"></span>
</div>
</div>
<div class="unlock-card-wrap">
<div class="unlock-card">
<div class="card-topline">
<div class="card-key-icon"><KeyRound :size="22" :stroke-width="2.1" /></div>
<div>
<p class="card-kicker">Welcome back</p>
<h2>{{ mode === 'sign-in' ? 'Unlock your vault' : 'Create your vault' }}</h2>
</div>
</div>
<div class="segmented-control" role="tablist" aria-label="Account action">
<button :class="{ active: mode === 'sign-in' }" role="tab" :aria-selected="mode === 'sign-in'" @click="mode = 'sign-in'">
Sign in
</button>
<button :class="{ active: mode === 'create' }" role="tab" :aria-selected="mode === 'create'" @click="mode = 'create'">
New account
</button>
</div>
<form class="unlock-form" @submit.prevent="submit">
<label v-if="mode === 'create'" class="field-label">
Your name
<input v-model="displayName" class="text-input" type="text" autocomplete="name" placeholder="Alex Morgan" />
</label>
<label class="field-label">
Email address
<input v-model="email" class="text-input" type="email" autocomplete="username" placeholder="you@example.com" required />
</label>
<label class="field-label">
Account password
<span class="field-hint">For your account, separate from your vault.</span>
<span class="password-input-wrap">
<input
v-model="accountPassword"
class="text-input"
:type="showAccountPassword ? 'text' : 'password'"
autocomplete="current-password"
placeholder="At least 8 characters"
minlength="8"
required
/>
<button type="button" class="input-action" aria-label="Toggle account password" @click="showAccountPassword = !showAccountPassword">
<EyeOff v-if="showAccountPassword" :size="16" />
<Eye v-else :size="16" />
</button>
</span>
</label>
<label class="field-label">
Master password
<span class="field-hint">This is the only key to your encrypted vault.</span>
<span class="password-input-wrap">
<input
v-model="masterPassword"
class="text-input text-input--master"
:type="showMasterPassword ? 'text' : 'password'"
autocomplete="new-password"
placeholder="Enter your master password"
minlength="8"
required
/>
<button type="button" class="input-action" aria-label="Toggle master password" @click="showMasterPassword = !showMasterPassword">
<EyeOff v-if="showMasterPassword" :size="16" />
<Eye v-else :size="16" />
</button>
</span>
</label>
<div v-if="errorMessage" class="form-error" role="alert">{{ errorMessage }}</div>
<button class="primary-button primary-button--full" type="submit" :disabled="busy">
<span>{{ busy ? 'Unlocking…' : mode === 'sign-in' ? 'Unlock vault' : 'Create & unlock' }}</span>
<ArrowRight :size="17" />
</button>
</form>
<div class="unlock-card-footer">
<span><LockKeyhole :size="13" /> End-to-end encrypted</span>
<span class="footer-separator">·</span>
<button type="button" class="text-button">Need help?</button>
</div>
</div>
<div v-if="demoMode" class="demo-strip">
<div class="demo-strip-icon"><SparklesIcon /></div>
<div><strong>Local preview mode</strong><span>Explore the vault with synthetic data.</span></div>
<button type="button" @click="fillPreview">Enter preview</button>
</div>
</div>
</section>
<footer class="unlock-footer shell-width">
<span>Keychain <span class="muted-dot">·</span> Made for quieter digital lives</span>
<span>Local-first MVP <span class="muted-dot">·</span> No tracking</span>
</footer>
</main>
</template>
<script lang="ts">
import { Sparkles as SparklesIcon } from "lucide-vue-next";
export default { components: { SparklesIcon } };
</script>

View file

@ -0,0 +1,359 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import {
ArrowLeft,
Check,
ChevronDown,
CircleHelp,
Copy,
CreditCard,
ExternalLink,
Eye,
EyeOff,
FileText,
Grid2X2,
KeyRound,
LockKeyhole,
LogOut,
Menu,
Moon,
MoreHorizontal,
Plus,
Search,
Settings2,
ShieldCheck,
ShieldPlus,
Sparkles,
Star,
StickyNote,
Sun,
Trash2,
UserRound,
X,
} from "lucide-vue-next";
import type { EntryCategory, User, VaultDocument, VaultEntry } from "@keychain/core";
type Theme = "light" | "dark";
type Filter = "all" | "favorites" | EntryCategory;
const props = defineProps<{
demoMode: boolean;
user: User;
vault: VaultDocument;
theme: Theme;
}>();
const emit = defineEmits<{
lock: [];
"sign-out": [];
save: [document: VaultDocument];
"theme-change": [theme: Theme];
}>();
const query = ref("");
const filter = ref<Filter>("all");
const selectedId = ref(props.vault.entries[0]?.id ?? null);
const mobileDetailOpen = ref(false);
const mobileMenuOpen = ref(false);
const newEntryOpen = ref(false);
const revealPassword = ref(false);
const toast = ref("");
const workingEntries = ref<VaultEntry[]>(props.vault.entries.map((entry) => ({ ...entry })));
const newEntry = ref({ title: "", username: "", password: "", url: "", category: "login" as EntryCategory });
watch(
() => props.vault,
(nextVault) => {
workingEntries.value = nextVault.entries.map((entry) => ({ ...entry }));
if (!workingEntries.value.some((entry) => entry.id === selectedId.value)) {
selectedId.value = workingEntries.value[0]?.id ?? null;
}
},
{ deep: true },
);
const selectedEntry = computed(() => workingEntries.value.find((entry) => entry.id === selectedId.value) ?? null);
const categories = computed(() => [
{ key: "login" as const, label: "Passwords", count: workingEntries.value.filter((entry) => entry.category === "login").length, icon: KeyRound },
{ key: "card" as const, label: "Cards", count: workingEntries.value.filter((entry) => entry.category === "card").length, icon: CreditCard },
{ key: "note" as const, label: "Secure notes", count: workingEntries.value.filter((entry) => entry.category === "note").length, icon: StickyNote },
{ key: "identity" as const, label: "Identities", count: workingEntries.value.filter((entry) => entry.category === "identity").length, icon: UserRound },
]);
const filteredEntries = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase();
return [...workingEntries.value]
.filter((entry) => {
const matchesFilter =
filter.value === "all" ||
(filter.value === "favorites" ? Boolean(entry.favorite) : entry.category === filter.value);
const haystack = `${entry.title} ${entry.username} ${entry.url}`.toLowerCase();
return matchesFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
})
.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)) || b.updatedAt.localeCompare(a.updatedAt));
});
const favoriteCount = computed(() => workingEntries.value.filter((entry) => entry.favorite).length);
function selectEntry(entry: VaultEntry) {
selectedId.value = entry.id;
revealPassword.value = false;
mobileDetailOpen.value = true;
}
function categoryLabel(category: EntryCategory) {
return { login: "Password", card: "Card", note: "Secure note", identity: "Identity" }[category];
}
function iconForEntry(entry: VaultEntry) {
const palette = ["blue", "violet", "mint", "orange", "pink", "indigo"];
const score = [...entry.title].reduce((sum, character) => sum + character.charCodeAt(0), 0);
return palette[score % palette.length];
}
function initials(title: string) {
const words = title.split(/\s+/).filter(Boolean);
return words.length > 1 ? `${words[0][0]}${words[1][0]}`.toUpperCase() : title.slice(0, 2).toUpperCase();
}
function relativeDate(date: string) {
const timestamp = Date.parse(date);
if (!Number.isFinite(timestamp)) return "Recently";
const days = Math.floor((Date.now() - timestamp) / 86_400_000);
if (days <= 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(timestamp);
}
function persist() {
emit("save", { version: 1, entries: workingEntries.value.map((entry) => ({ ...entry })) });
}
function toggleFavorite(entry: VaultEntry) {
entry.favorite = !entry.favorite;
entry.updatedAt = new Date().toISOString();
persist();
showToast(entry.favorite ? "Added to favorites" : "Removed from favorites");
}
async function copySecret(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
showToast(`${label} copied to clipboard`);
} catch {
showToast("Clipboard access is unavailable");
}
}
let toastTimer: number | undefined;
function showToast(message: string) {
toast.value = message;
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => (toast.value = ""), 2400);
}
function resetNewEntry() {
newEntry.value = { title: "", username: "", password: "", url: "", category: "login" };
}
function createEntry() {
const title = newEntry.value.title.trim();
if (!title) return;
const now = new Date().toISOString();
const entry: VaultEntry = {
id: crypto.randomUUID(),
title,
username: newEntry.value.username.trim(),
password: newEntry.value.password,
url: newEntry.value.url.trim(),
category: newEntry.value.category,
createdAt: now,
updatedAt: now,
};
workingEntries.value = [entry, ...workingEntries.value];
selectedId.value = entry.id;
newEntryOpen.value = false;
resetNewEntry();
persist();
showToast("Password added to your vault");
}
function generatePassword() {
newEntry.value.password = `${crypto.randomUUID().slice(0, 8)}-${crypto.randomUUID().slice(0, 8)}`;
}
function removeSelected() {
if (!selectedEntry.value) return;
const title = selectedEntry.value.title;
workingEntries.value = workingEntries.value.filter((entry) => entry.id !== selectedEntry.value?.id);
selectedId.value = workingEntries.value[0]?.id ?? null;
mobileDetailOpen.value = false;
persist();
showToast(`${title} removed`);
}
function openFilter(nextFilter: Filter) {
filter.value = nextFilter;
mobileMenuOpen.value = false;
}
</script>
<template>
<div class="vault-shell">
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="mobileMenuOpen = false"></div>
<aside class="vault-sidebar" :class="{ 'vault-sidebar--open': mobileMenuOpen }">
<div class="sidebar-header">
<div class="brand-lockup">
<span class="brand-mark"><KeyRound :size="18" :stroke-width="2.4" /></span>
<span class="brand-wordmark">Keychain</span>
</div>
<button class="icon-button sidebar-close" aria-label="Close navigation" @click="mobileMenuOpen = false"><X :size="18" /></button>
</div>
<nav class="sidebar-nav" aria-label="Main navigation">
<button class="sidebar-link sidebar-link--active" @click="openFilter('all')">
<Grid2X2 :size="17" /><span>All items</span><span class="sidebar-count">{{ workingEntries.length }}</span>
</button>
<button class="sidebar-link" :class="{ 'sidebar-link--selected': filter === 'favorites' }" @click="openFilter('favorites')">
<Star :size="17" /><span>Favorites</span><span class="sidebar-count">{{ favoriteCount }}</span>
</button>
</nav>
<div class="sidebar-section">
<div class="sidebar-section-heading"><span>Collections</span><button class="small-icon-button" aria-label="Add collection"><Plus :size="15" /></button></div>
<button
v-for="category in categories"
:key="category.key"
class="sidebar-link"
:class="{ 'sidebar-link--selected': filter === category.key }"
@click="openFilter(category.key)"
>
<component :is="category.icon" :size="17" />
<span>{{ category.label }}</span>
<span class="sidebar-count">{{ category.count }}</span>
</button>
</div>
<div class="sidebar-spacer"></div>
<div class="sidebar-security-card">
<div class="security-card-orb"><ShieldCheck :size="16" /></div>
<div><strong>Vault health</strong><span>Everything looks good</span></div>
<span class="health-dot"></span>
</div>
<div class="sidebar-footer">
<button class="sidebar-link" @click="showToast('Settings are coming next')"><Settings2 :size="17" /><span>Settings</span></button>
<button class="sidebar-link" @click="showToast('Help center is coming next')"><CircleHelp :size="17" /><span>Help center</span></button>
<div class="account-row">
<div class="avatar">{{ initials(user.display_name) }}</div>
<div class="account-copy"><strong>{{ user.display_name }}</strong><span>{{ user.email }}</span></div>
<button class="account-menu" aria-label="Account menu" @click="emit('sign-out')"><LogOut :size="15" /></button>
</div>
</div>
</aside>
<main class="vault-main">
<header class="vault-topbar">
<div class="topbar-left">
<button class="icon-button mobile-menu-button" aria-label="Open navigation" @click="mobileMenuOpen = true"><Menu :size="20" /></button>
<div class="breadcrumb"><span>Workspace</span><ChevronDown :size="14" /><strong>Personal vault</strong></div>
</div>
<div class="topbar-actions">
<label class="search-box">
<Search :size="16" />
<input v-model="query" type="search" placeholder="Search your vault" aria-label="Search your vault" />
<kbd> K</kbd>
</label>
<button class="icon-button" :aria-label="theme === 'light' ? 'Use dark theme' : 'Use light theme'" @click="emit('theme-change', theme === 'light' ? 'dark' : 'light')">
<Moon v-if="theme === 'light'" :size="17" /><Sun v-else :size="17" />
</button>
<button class="lock-button" @click="emit('lock')"><LockKeyhole :size="15" /><span>Lock</span></button>
</div>
</header>
<section class="vault-content">
<div class="vault-heading-row">
<div>
<div class="eyebrow"><span class="eyebrow-line"></span> Personal vault</div>
<h1>Your passwords</h1>
<p class="section-lede">A secure place for the things you want to keep close.</p>
</div>
<button class="primary-button" @click="newEntryOpen = true"><Plus :size="17" /><span>New password</span></button>
</div>
<div class="vault-insight-card">
<div class="insight-copy">
<div class="insight-icon"><ShieldPlus :size="19" /></div>
<div><span class="insight-kicker">Looking good</span><strong>Your vault is healthy</strong><p>All {{ workingEntries.length }} items are protected and up to date.</p></div>
</div>
<div class="insight-meter"><div class="meter-label"><span>Security score</span><strong>Excellent</strong></div><div class="meter-track"><span></span></div></div>
<div class="insight-decor" aria-hidden="true"><span></span><span></span><span></span></div>
</div>
<div class="content-columns">
<section class="entries-card">
<div class="entries-header">
<div><h2>All items</h2><span>{{ filteredEntries.length }} {{ filteredEntries.length === 1 ? 'item' : 'items' }}</span></div>
<div class="entries-tools"><button class="filter-button"><span>{{ filter === 'all' ? 'Recently updated' : filter === 'favorites' ? 'Favorites' : categoryLabel(filter) }}</span><ChevronDown :size="14" /></button><button class="icon-button icon-button--card" aria-label="More list options"><MoreHorizontal :size="18" /></button></div>
</div>
<div v-if="filteredEntries.length" class="entry-list">
<button
v-for="entry in filteredEntries"
:key="entry.id"
class="entry-row"
:class="{ 'entry-row--active': selectedId === entry.id }"
@click="selectEntry(entry)"
>
<span class="entry-logo" :class="`entry-logo--${iconForEntry(entry)}`">{{ initials(entry.title) }}</span>
<span class="entry-summary"><strong>{{ entry.title }}</strong><span>{{ entry.username || 'No username added' }}</span></span>
<span class="entry-meta"><span class="entry-category">{{ categoryLabel(entry.category) }}</span><span>{{ relativeDate(entry.updatedAt) }}</span></span>
<Star v-if="entry.favorite" class="entry-star" :size="14" fill="currentColor" />
<ChevronDown class="entry-chevron" :size="16" />
</button>
</div>
<div v-else class="empty-state"><div class="empty-state-icon"><Search :size="20" /></div><strong>No items found</strong><span>Try another search or create a new password.</span></div>
<div class="entries-footer"><span><ShieldCheck :size="14" /> Encrypted locally</span><span>Last synced just now</span></div>
</section>
<aside v-if="selectedEntry" class="detail-panel" :class="{ 'detail-panel--mobile-open': mobileDetailOpen }">
<div class="detail-topline"><button class="mobile-detail-back" @click="mobileDetailOpen = false"><ArrowLeft :size="16" /> All passwords</button><button class="icon-button icon-button--card" aria-label="Close details" @click="mobileDetailOpen = false"><X :size="17" /></button></div>
<div class="detail-hero">
<div class="detail-logo" :class="`entry-logo--${iconForEntry(selectedEntry)}`">{{ initials(selectedEntry.title) }}</div>
<div class="detail-title"><span>{{ categoryLabel(selectedEntry.category) }}</span><h2>{{ selectedEntry.title }}</h2><a v-if="selectedEntry.url" :href="selectedEntry.url" target="_blank" rel="noreferrer">{{ selectedEntry.url.replace(/^https?:\/\//, '').replace(/\/$/, '') }} <ExternalLink :size="12" /></a></div>
<button class="favorite-button" :class="{ 'favorite-button--active': selectedEntry.favorite }" :aria-label="selectedEntry.favorite ? 'Remove favorite' : 'Add favorite'" @click="toggleFavorite(selectedEntry)"><Star :size="18" :fill="selectedEntry.favorite ? 'currentColor' : 'none'" /></button>
</div>
<div class="detail-divider"></div>
<div class="detail-fields">
<div class="detail-field"><span class="detail-label">Username</span><div class="detail-value-row"><strong>{{ selectedEntry.username || 'Not added' }}</strong><button class="copy-button" aria-label="Copy username" @click="copySecret(selectedEntry.username, 'Username')"><Copy :size="15" /></button></div></div>
<div class="detail-field"><span class="detail-label">Password</span><div class="detail-value-row"><strong class="secret-text">{{ revealPassword ? selectedEntry.password : '••••••••••••' }}</strong><div class="value-actions"><button class="copy-button" :aria-label="revealPassword ? 'Hide password' : 'Show password'" @click="revealPassword = !revealPassword"><EyeOff v-if="revealPassword" :size="15" /><Eye v-else :size="15" /></button><button class="copy-button" aria-label="Copy password" @click="copySecret(selectedEntry.password, 'Password')"><Copy :size="15" /></button></div></div></div>
<div v-if="selectedEntry.notes" class="detail-field"><span class="detail-label">Notes</span><div class="detail-note"><FileText :size="14" /><span>{{ selectedEntry.notes }}</span></div></div>
</div>
<div class="detail-security-note"><div class="security-note-icon"><LockKeyhole :size="15" /></div><div><strong>Stored securely</strong><span>Encrypted on this device with your master password.</span></div></div>
<div class="detail-footer"><span>Updated {{ relativeDate(selectedEntry.updatedAt).toLowerCase() }}</span><button class="danger-button" @click="removeSelected"><Trash2 :size="14" /> Remove</button></div>
</aside>
</div>
</section>
</main>
<div v-if="newEntryOpen" class="modal-backdrop" @click.self="newEntryOpen = false">
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="new-entry-title">
<div class="modal-header"><div><span class="card-kicker">Add to your vault</span><h2 id="new-entry-title">New password</h2></div><button class="icon-button icon-button--card" aria-label="Close" @click="newEntryOpen = false"><X :size="18" /></button></div>
<form class="modal-form" @submit.prevent="createEntry">
<label class="field-label">Name<input v-model="newEntry.title" class="text-input" placeholder="e.g. Dropbox" required /></label>
<label class="field-label">Website URL<span class="field-hint">Used by the extension to match this login.</span><input v-model="newEntry.url" class="text-input" type="url" placeholder="https://example.com" /></label>
<div class="form-two-col"><label class="field-label">Username<input v-model="newEntry.username" class="text-input" autocomplete="off" placeholder="you@example.com" /></label><label class="field-label">Category<select v-model="newEntry.category" class="text-input"><option value="login">Password</option><option value="card">Card</option><option value="note">Secure note</option><option value="identity">Identity</option></select></label></div>
<label class="field-label">Password<span class="password-input-wrap"><input v-model="newEntry.password" class="text-input text-input--master" type="password" autocomplete="new-password" placeholder="Enter a password" /><button type="button" class="input-action" aria-label="Generate password" @click="generatePassword"><Sparkles :size="16" /></button></span></label>
<div class="modal-actions"><button type="button" class="secondary-button" @click="newEntryOpen = false">Cancel</button><button type="submit" class="primary-button"><Plus :size="16" /> Add password</button></div>
</form>
</section>
</div>
<Transition name="toast"><div v-if="toast" class="toast-message"><Check :size="15" />{{ toast }}</div></Transition>
</div>
</template>

9
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

10
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
host: "0.0.0.0",
},
});

16
docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
services:
api:
build:
context: ./apps/api
environment:
KEYCHAIN_ENV: development
KEYCHAIN_DB_PATH: /data/keychain.db
KEYCHAIN_ALLOWED_ORIGINS: http://localhost:5173
KEYCHAIN_COOKIE_SECURE: "false"
ports:
- "8000:8000"
volumes:
- keychain-data:/data
volumes:
keychain-data:

1525
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "keychain-monorepo",
"version": "0.1.0",
"private": true,
"description": "A calm, client-encrypted password vault with a cross-platform browser extension.",
"workspaces": [
"apps/web",
"apps/extension",
"packages/core"
],
"scripts": {
"dev": "npm run dev --workspace=@keychain/web",
"dev:web": "npm run dev --workspace=@keychain/web",
"dev:api": "python3 -m uvicorn app.main:app --reload --app-dir apps/api --port 8000",
"build": "npm run build --workspace=@keychain/web && npm run build --workspace=@keychain/extension",
"typecheck": "npm run typecheck --workspace=@keychain/core && npm run typecheck --workspace=@keychain/web && npm run typecheck --workspace=@keychain/extension",
"api:test": "python3 -m unittest discover -s apps/api/tests"
},
"engines": {
"node": ">=20"
}
}

View file

@ -0,0 +1,19 @@
{
"name": "@keychain/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"hash-wasm": "^4.12.0"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

74
packages/core/src/api.ts Normal file
View file

@ -0,0 +1,74 @@
import type { AuthResponse, User, VaultEnvelope, VaultResponse } from "./types";
export class KeychainApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "KeychainApiError";
this.status = status;
}
}
export class KeychainApiClient {
private csrfToken = "";
private readonly baseUrl: string;
constructor(baseUrl = "http://localhost:8000/api") {
this.baseUrl = baseUrl.replace(/\/$/, "");
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (this.csrfToken) headers.set("X-CSRF-Token", this.csrfToken);
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers,
credentials: "include",
});
const body = (await response.json().catch(() => ({}))) as { detail?: string } & T;
if (!response.ok) {
throw new KeychainApiError(body.detail || "Something went wrong", response.status);
}
return body as T;
}
async register(email: string, password: string, displayName?: string): Promise<User> {
const response = await this.request<AuthResponse>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, display_name: displayName }),
});
this.csrfToken = response.csrf_token;
return response.user;
}
async login(email: string, password: string): Promise<User> {
const response = await this.request<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
this.csrfToken = response.csrf_token;
return response.user;
}
async me(): Promise<User> {
return (await this.request<{ user: User }>("/auth/me")).user;
}
async getVault(): Promise<VaultResponse> {
return this.request<VaultResponse>("/vault");
}
async saveVault(envelope: VaultEnvelope, revision: number): Promise<{ revision: number; updated_at: string }> {
return this.request("/vault", {
method: "PUT",
body: JSON.stringify({ envelope, revision }),
});
}
async logout(): Promise<void> {
await this.request("/auth/logout", { method: "POST" });
this.csrfToken = "";
}
}

113
packages/core/src/crypto.ts Normal file
View file

@ -0,0 +1,113 @@
import { argon2id } from "hash-wasm";
import type {
Argon2idParams,
VaultDocument,
VaultEnvelope,
} from "./types";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const AAD_PREFIX = "keychain.vault.v1";
export const DEFAULT_KDF_PARAMS: Argon2idParams = {
algorithm: "argon2id",
memoryKiB: 19_456,
iterations: 2,
parallelism: 1,
keyLength: 32,
};
function randomBytes(length: number): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length));
}
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function fromBase64(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
async function deriveKey(masterPassword: string, salt: Uint8Array, params: Argon2idParams): Promise<CryptoKey> {
const rawKey = await argon2id({
password: masterPassword,
salt,
iterations: params.iterations,
memorySize: params.memoryKiB,
parallelism: params.parallelism,
hashLength: params.keyLength,
outputType: "binary",
});
return crypto.subtle.importKey("raw", toArrayBuffer(rawKey), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
function associatedData(userId: string): Uint8Array {
return encoder.encode(`${AAD_PREFIX}:${userId}`);
}
export async function encryptVault(
document: VaultDocument,
masterPassword: string,
userId: string,
kdf: Argon2idParams = DEFAULT_KDF_PARAMS,
): Promise<VaultEnvelope> {
if (!masterPassword) throw new Error("Master password is required");
const salt = randomBytes(16);
const nonce = randomBytes(12);
const key = await deriveKey(masterPassword, salt, kdf);
const plaintext = encoder.encode(JSON.stringify(document));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: toArrayBuffer(nonce), additionalData: toArrayBuffer(associatedData(userId)), tagLength: 128 },
key,
toArrayBuffer(plaintext),
);
return {
version: 1,
algorithm: "AES-256-GCM",
kdf,
salt: toBase64(salt),
nonce: toBase64(nonce),
ciphertext: toBase64(new Uint8Array(encrypted)),
aad: `${AAD_PREFIX}:${userId}`,
};
}
export async function decryptVault(
envelope: VaultEnvelope,
masterPassword: string,
userId: string,
): Promise<VaultDocument> {
if (envelope.version !== 1 || envelope.algorithm !== "AES-256-GCM") {
throw new Error("Unsupported vault envelope");
}
if (envelope.aad !== `${AAD_PREFIX}:${userId}`) {
throw new Error("Vault belongs to a different account");
}
const salt = fromBase64(envelope.salt);
const nonce = fromBase64(envelope.nonce);
const key = await deriveKey(masterPassword, salt, envelope.kdf);
try {
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: toArrayBuffer(nonce), additionalData: toArrayBuffer(associatedData(userId)), tagLength: 128 },
key,
toArrayBuffer(fromBase64(envelope.ciphertext)),
);
const document = JSON.parse(decoder.decode(decrypted)) as VaultDocument;
if (document.version !== 1 || !Array.isArray(document.entries)) {
throw new Error("Vault document is invalid");
}
return document;
} catch {
throw new Error("Master password is incorrect or the vault is corrupted");
}
}

View file

@ -0,0 +1,3 @@
export * from "./api";
export * from "./crypto";
export * from "./types";

View file

@ -0,0 +1,54 @@
export type EntryCategory = "login" | "card" | "note" | "identity";
export interface User {
id: string;
email: string;
display_name: string;
}
export interface VaultEntry {
id: string;
title: string;
username: string;
password: string;
url: string;
notes?: string;
category: EntryCategory;
favorite?: boolean;
createdAt: string;
updatedAt: string;
}
export interface VaultDocument {
version: 1;
entries: VaultEntry[];
}
export interface Argon2idParams {
algorithm: "argon2id";
memoryKiB: number;
iterations: number;
parallelism: number;
keyLength: number;
}
export interface VaultEnvelope {
version: 1;
algorithm: "AES-256-GCM";
kdf: Argon2idParams;
salt: string;
nonce: string;
ciphertext: string;
aad: string;
}
export interface VaultResponse {
envelope: VaultEnvelope | null;
revision: number;
updated_at: string;
}
export interface AuthResponse {
user: User;
csrf_token: string;
}

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

15
tsconfig.base.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noEmit": true
}
}