feat: build secure keychain monorepo
This commit is contained in:
commit
a14a5d2471
41 changed files with 5725 additions and 0 deletions
11
.env.example
Normal file
11
.env.example
Normal 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
17
.gitignore
vendored
Normal 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
56
README.md
Normal 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
18
SECURITY.md
Normal 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
11
apps/api/Dockerfile
Normal 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
1
apps/api/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Keychain API package."""
|
||||
364
apps/api/app/main.py
Normal file
364
apps/api/app/main.py
Normal 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}
|
||||
3
apps/api/requirements.txt
Normal file
3
apps/api/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
argon2-cffi>=23.1,<26.0
|
||||
0
apps/api/tests/__init__.py
Normal file
0
apps/api/tests/__init__.py
Normal file
14
apps/api/tests/test_api.py
Normal file
14
apps/api/tests/test_api.py
Normal 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()
|
||||
37
apps/extension/manifest.json
Normal file
37
apps/extension/manifest.json
Normal 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": {}
|
||||
}
|
||||
12
apps/extension/options.html
Normal file
12
apps/extension/options.html
Normal 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>
|
||||
19
apps/extension/package.json
Normal file
19
apps/extension/package.json
Normal 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
12
apps/extension/popup.html
Normal 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>
|
||||
75
apps/extension/src/background.ts
Normal file
75
apps/extension/src/background.ts
Normal 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;
|
||||
});
|
||||
62
apps/extension/src/content.ts
Normal file
62
apps/extension/src/content.ts
Normal 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");
|
||||
});
|
||||
63
apps/extension/src/extension.css
Normal file
63
apps/extension/src/extension.css
Normal 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; }
|
||||
26
apps/extension/src/extension.ts
Normal file
26
apps/extension/src/extension.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
apps/extension/src/options.ts
Normal file
37
apps/extension/src/options.ts
Normal 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
122
apps/extension/src/popup.ts
Normal 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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[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();
|
||||
7
apps/extension/tsconfig.json
Normal file
7
apps/extension/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
34
apps/extension/vite.config.ts
Normal file
34
apps/extension/vite.config.ts
Normal 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
14
apps/web/index.html
Normal 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
22
apps/web/package.json
Normal 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
228
apps/web/src/App.vue
Normal 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
5
apps/web/src/main.ts
Normal 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
2034
apps/web/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
198
apps/web/src/views/UnlockView.vue
Normal file
198
apps/web/src/views/UnlockView.vue
Normal 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>
|
||||
359
apps/web/src/views/VaultView.vue
Normal file
359
apps/web/src/views/VaultView.vue
Normal 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
9
apps/web/tsconfig.json
Normal 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
10
apps/web/vite.config.ts
Normal 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
16
docker-compose.yml
Normal 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
1525
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
Normal file
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
packages/core/package.json
Normal file
19
packages/core/package.json
Normal 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
74
packages/core/src/api.ts
Normal 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
113
packages/core/src/crypto.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
3
packages/core/src/index.ts
Normal file
3
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./api";
|
||||
export * from "./crypto";
|
||||
export * from "./types";
|
||||
54
packages/core/src/types.ts
Normal file
54
packages/core/src/types.ts
Normal 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;
|
||||
}
|
||||
4
packages/core/tsconfig.json
Normal file
4
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue