From a14a5d2471cd663098fd677ca6f76dc8689dc322 Mon Sep 17 00:00:00 2001 From: Keychain Builder Date: Wed, 9 Sep 2026 19:53:50 +0000 Subject: [PATCH] feat: build secure keychain monorepo --- .env.example | 11 + .gitignore | 17 + README.md | 56 + SECURITY.md | 18 + apps/api/Dockerfile | 11 + apps/api/app/__init__.py | 1 + apps/api/app/main.py | 364 ++++++ apps/api/requirements.txt | 3 + apps/api/tests/__init__.py | 0 apps/api/tests/test_api.py | 14 + apps/extension/manifest.json | 37 + apps/extension/options.html | 12 + apps/extension/package.json | 19 + apps/extension/popup.html | 12 + apps/extension/src/background.ts | 75 ++ apps/extension/src/content.ts | 62 + apps/extension/src/extension.css | 63 + apps/extension/src/extension.ts | 26 + apps/extension/src/options.ts | 37 + apps/extension/src/popup.ts | 122 ++ apps/extension/tsconfig.json | 7 + apps/extension/vite.config.ts | 34 + apps/web/index.html | 14 + apps/web/package.json | 22 + apps/web/src/App.vue | 228 ++++ apps/web/src/main.ts | 5 + apps/web/src/styles.css | 2034 +++++++++++++++++++++++++++++ apps/web/src/views/UnlockView.vue | 198 +++ apps/web/src/views/VaultView.vue | 359 +++++ apps/web/tsconfig.json | 9 + apps/web/vite.config.ts | 10 + docker-compose.yml | 16 + package-lock.json | 1525 +++++++++++++++++++++ package.json | 22 + packages/core/package.json | 19 + packages/core/src/api.ts | 74 ++ packages/core/src/crypto.ts | 113 ++ packages/core/src/index.ts | 3 + packages/core/src/types.ts | 54 + packages/core/tsconfig.json | 4 + tsconfig.base.json | 15 + 41 files changed, 5725 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/app/__init__.py create mode 100644 apps/api/app/main.py create mode 100644 apps/api/requirements.txt create mode 100644 apps/api/tests/__init__.py create mode 100644 apps/api/tests/test_api.py create mode 100644 apps/extension/manifest.json create mode 100644 apps/extension/options.html create mode 100644 apps/extension/package.json create mode 100644 apps/extension/popup.html create mode 100644 apps/extension/src/background.ts create mode 100644 apps/extension/src/content.ts create mode 100644 apps/extension/src/extension.css create mode 100644 apps/extension/src/extension.ts create mode 100644 apps/extension/src/options.ts create mode 100644 apps/extension/src/popup.ts create mode 100644 apps/extension/tsconfig.json create mode 100644 apps/extension/vite.config.ts create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.vue create mode 100644 apps/web/src/main.ts create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/views/UnlockView.vue create mode 100644 apps/web/src/views/VaultView.vue create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/api.ts create mode 100644 packages/core/src/crypto.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3278c1c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f53115d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b321cc --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ad273ce --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..daa850e --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..8aac9c2 --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ +"""Keychain API package.""" diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..3f62373 --- /dev/null +++ b/apps/api/app/main.py @@ -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} diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 0000000..063226f --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30,<1.0 +argon2-cffi>=23.1,<26.0 diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py new file mode 100644 index 0000000..34e729c --- /dev/null +++ b/apps/api/tests/test_api.py @@ -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() diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json new file mode 100644 index 0000000..0e9a7d9 --- /dev/null +++ b/apps/extension/manifest.json @@ -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": {} +} diff --git a/apps/extension/options.html b/apps/extension/options.html new file mode 100644 index 0000000..d80ed8c --- /dev/null +++ b/apps/extension/options.html @@ -0,0 +1,12 @@ + + + + + + Keychain settings + + +
+ + + diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 0000000..fa4bfda --- /dev/null +++ b/apps/extension/package.json @@ -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" + } +} diff --git a/apps/extension/popup.html b/apps/extension/popup.html new file mode 100644 index 0000000..5bd7c6f --- /dev/null +++ b/apps/extension/popup.html @@ -0,0 +1,12 @@ + + + + + + Keychain + + + + + + diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts new file mode 100644 index 0000000..3e993c2 --- /dev/null +++ b/apps/extension/src/background.ts @@ -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; + 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; +}); diff --git a/apps/extension/src/content.ts b/apps/extension/src/content.ts new file mode 100644 index 0000000..1cd996f --- /dev/null +++ b/apps/extension/src/content.ts @@ -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("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"); +}); diff --git a/apps/extension/src/extension.css b/apps/extension/src/extension.css new file mode 100644 index 0000000..8c4619e --- /dev/null +++ b/apps/extension/src/extension.css @@ -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; } diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts new file mode 100644 index 0000000..f429515 --- /dev/null +++ b/apps/extension/src/extension.ts @@ -0,0 +1,26 @@ +export interface ExtensionSettings { + apiUrl: string; +} + +export const DEFAULT_SETTINGS: ExtensionSettings = { + apiUrl: "http://localhost:8000/api", +}; + +export async function getSettings(): Promise { + 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; + } +} diff --git a/apps/extension/src/options.ts b/apps/extension/src/options.ts new file mode 100644 index 0000000..c871e55 --- /dev/null +++ b/apps/extension/src/options.ts @@ -0,0 +1,37 @@ +import { DEFAULT_SETTINGS, getSettings } from "./extension"; +import "./extension.css"; + +const rootElement = document.querySelector("#options-app"); +if (!rootElement) throw new Error("Options root is missing"); +const root = rootElement; + +async function start() { + const settings = await getSettings(); + root.innerHTML = ` +
+
Keychain
+ Extension settings

Connect your vault

+

Use the same API origin as the web app. The extension stores only this endpoint and never stores your master password.

+
+
+ Default: ${DEFAULT_SETTINGS.apiUrl}. For production, use an HTTPS API. +
`; + document.querySelector("#settings-form")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const input = document.querySelector("#api-url"); + const status = document.querySelector("#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(); diff --git a/apps/extension/src/popup.ts b/apps/extension/src/popup.ts new file mode 100644 index 0000000..dce2cb4 --- /dev/null +++ b/apps/extension/src/popup.ts @@ -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("#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 = ` +
+
Keychain
Locked
+
Fill for${originLabel}
+
Private by design

Unlock to fill

Your master password stays in this browser and is never sent to the server.

+
+ + + + ${errorMessage ? `
${escapeHtml(errorMessage)}
` : ""} + +
+
Encrypted locally
+
`; + document.querySelector("#unlock-extension")?.addEventListener("submit", unlock); + document.querySelector("#open-options")?.addEventListener("click", () => chrome.runtime.openOptionsPage()); + return; + } + + const matches = entries.filter((entry) => originFromUrl(entry.url) === activeOrigin); + root.innerHTML = ` +
+
Keychain
+
Matches for${originLabel}
+
Ready to fill

${matches.length ? `${matches.length} login${matches.length === 1 ? "" : "s"} found` : "No login found"}

${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."}

+
${matches.map((entry) => ``).join("") || `
Nothing for this origin yetExact origin matching protects you from accidental fills.
`}
+
Unlocked in memory only
+
`; + document.querySelectorAll("[data-entry-id]").forEach((button) => button.addEventListener("click", () => fill(button.dataset.entryId || ""))); + document.querySelector("#lock-extension")?.addEventListener("click", lock); + document.querySelector("#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(); diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 0000000..79219b6 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["chrome"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts new file mode 100644 index 0000000..11b5869 --- /dev/null +++ b/apps/extension/vite.config.ts @@ -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")); + }, + }, + ], +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..61a8c03 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Keychain — your private vault + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..7bb24e2 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..cbd6f85 --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,228 @@ + + + diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..8d6a5ff --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./styles.css"; + +createApp(App).mount("#app"); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..61cef24 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,2034 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&display=swap"); + +:root { + color-scheme: light; + --page: #f5f7fb; + --page-deep: #eef2f8; + --surface: rgba(255, 255, 255, 0.9); + --surface-solid: #ffffff; + --surface-muted: #f8f9fc; + --surface-hover: #f1f4fa; + --ink: #172033; + --ink-soft: #435069; + --muted: #8b95a8; + --muted-strong: #707c92; + --line: #e6eaf1; + --line-strong: #dbe1ea; + --blue: #526fe8; + --blue-dark: #3f5dd8; + --blue-soft: #eef2ff; + --purple: #8272ea; + --green: #52ad83; + --green-soft: #eaf8f1; + --red: #dc7180; + --shadow-sm: 0 2px 10px rgba(27, 43, 76, 0.04); + --shadow-md: 0 18px 50px rgba(35, 51, 87, 0.08); + --radius-lg: 22px; + --radius-md: 15px; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page: #111522; + --page-deep: #0d101b; + --surface: rgba(28, 34, 50, 0.93); + --surface-solid: #1c2232; + --surface-muted: #181e2c; + --surface-hover: #242c40; + --ink: #eef2fb; + --ink-soft: #b7c1d4; + --muted: #7c879d; + --muted-strong: #a7b0c3; + --line: #2b3448; + --line-strong: #3a455d; + --blue: #8195ff; + --blue-dark: #9dadff; + --blue-soft: #242b50; + --purple: #a99cff; + --green: #6dcc9d; + --green-soft: #17352a; + --red: #f08a97; + --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.16); + --shadow-md: 0 18px 50px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; + margin: 0; +} + +body { + min-width: 320px; + color: var(--ink); + background: var(--page); + font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +a:focus-visible { + outline: 3px solid rgba(82, 111, 232, 0.28); + outline-offset: 2px; +} + +a { + color: inherit; +} + +.app-root { + min-height: 100vh; +} + +.shell-width { + width: min(1240px, calc(100% - 64px)); + margin-inline: auto; +} + +.brand-lockup { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + color: #fff; + background: linear-gradient(145deg, #7088f5 3%, #655dde 95%); + border-radius: 11px; + box-shadow: 0 6px 14px rgba(83, 96, 221, 0.24); +} + +.brand-mark--small { + width: 30px; + height: 30px; + border-radius: 10px; +} + +.brand-wordmark { + color: var(--ink); + font-family: "Manrope", sans-serif; + font-size: 17px; + font-weight: 800; + letter-spacing: -0.045em; +} + +.icon-button, +.small-icon-button, +.account-menu, +.copy-button, +.input-action, +.favorite-button { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + color: var(--muted-strong); + background: transparent; + border-radius: 9px; + transition: 160ms ease; +} + +.icon-button { + width: 36px; + height: 36px; +} + +.icon-button:hover, +.small-icon-button:hover, +.account-menu:hover, +.copy-button:hover, +.input-action:hover, +.favorite-button:hover { + color: var(--blue); + background: var(--surface-hover); +} + +.icon-button--quiet { + border: 1px solid var(--line); +} + +.primary-button, +.secondary-button, +.lock-button, +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 0 16px; + border-radius: 11px; + font-size: 13px; + font-weight: 700; + transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.primary-button { + color: #fff; + background: var(--blue); + box-shadow: 0 8px 16px rgba(82, 111, 232, 0.2); +} + +.primary-button:hover { + background: var(--blue-dark); + box-shadow: 0 10px 20px rgba(82, 111, 232, 0.27); + transform: translateY(-1px); +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.7; + transform: none; +} + +.secondary-button { + color: var(--ink-soft); + background: var(--surface-muted); + border: 1px solid var(--line); +} + +.secondary-button:hover { + background: var(--surface-hover); +} + +.text-button { + padding: 0; + color: var(--blue); + background: none; + font-size: 12px; +} + +/* Unlock page */ +.unlock-page { + position: relative; + min-height: 100vh; + overflow: hidden; + background: + radial-gradient(circle at 15% 6%, rgba(163, 182, 255, 0.18), transparent 28%), + radial-gradient(circle at 90% 74%, rgba(206, 194, 255, 0.16), transparent 31%), + var(--page); +} + +.unlock-page::before { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + content: ""; + opacity: 0.22; + background-image: radial-gradient(rgba(77, 96, 151, 0.18) 0.7px, transparent 0.7px); + background-size: 24px 24px; + mask-image: linear-gradient(125deg, black, transparent 60%); +} + +.unlock-nav, +.unlock-layout, +.unlock-footer { + position: relative; + z-index: 1; +} + +.unlock-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 27px; +} + +.unlock-nav-actions { + display: flex; + align-items: center; + gap: 14px; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted-strong); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.025em; +} + +.status-dot, +.health-dot { + display: inline-block; + width: 6px; + height: 6px; + background: var(--green); + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(82, 173, 131, 0.12); +} + +.unlock-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(390px, 450px); + align-items: center; + gap: clamp(48px, 9vw, 150px); + min-height: calc(100vh - 128px); + padding-block: 70px 86px; +} + +.unlock-intro { + position: relative; + max-width: 570px; + padding-left: clamp(0px, 3vw, 43px); +} + +.eyebrow { + display: flex; + align-items: center; + gap: 9px; + color: var(--blue); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.eyebrow-line { + display: inline-block; + width: 22px; + height: 1px; + background: currentColor; + opacity: 0.7; +} + +.unlock-intro h1 { + max-width: 600px; + margin: 20px 0 22px; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-size: clamp(39px, 5.1vw, 68px); + font-weight: 700; + letter-spacing: -0.075em; + line-height: 1.08; +} + +.unlock-intro h1 em { + color: var(--blue); + font-style: normal; +} + +.unlock-lede { + max-width: 440px; + margin: 0; + color: var(--muted-strong); + font-size: 15px; + line-height: 1.7; +} + +.trust-card { + position: relative; + display: flex; + align-items: center; + gap: 13px; + max-width: 430px; + margin-top: 42px; + padding: 16px 17px; + overflow: hidden; + background: rgba(255, 255, 255, 0.62); + border: 1px solid rgba(218, 224, 242, 0.9); + border-radius: 14px; + box-shadow: 0 10px 30px rgba(72, 91, 145, 0.05); + backdrop-filter: blur(12px); +} + +:root[data-theme="dark"] .trust-card { + background: rgba(29, 36, 55, 0.72); + border-color: var(--line); +} + +.trust-icon, +.security-card-orb { + display: grid; + flex: 0 0 auto; + width: 39px; + height: 39px; + place-items: center; + color: var(--green); + background: var(--green-soft); + border-radius: 12px; +} + +.trust-copy { + display: flex; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.trust-copy strong, +.trust-copy span { + position: relative; + z-index: 1; +} + +.trust-copy strong { + color: var(--ink-soft); + font-size: 12px; +} + +.trust-copy span { + color: var(--muted); + font-size: 11px; +} + +.trust-check { + display: grid; + width: 24px; + height: 24px; + place-items: center; + color: var(--green); + background: rgba(82, 173, 131, 0.1); + border-radius: 50%; +} + +.trust-orbit { + position: absolute; + width: 118px; + height: 118px; + pointer-events: none; + border: 1px solid rgba(82, 173, 131, 0.1); + border-radius: 50%; +} + +.trust-orbit--outer { + right: -44px; + bottom: -75px; +} + +.trust-orbit--inner { + right: -11px; + bottom: -41px; + width: 75px; + height: 75px; +} + +.unlock-sparkles { + position: absolute; + right: 3%; + bottom: -44px; + width: 150px; + height: 140px; + color: var(--purple); + opacity: 0.75; +} + +.sparkle { + position: absolute; + font-family: serif; +} + +.sparkle--one { + top: 22px; + right: 26px; + font-size: 28px; +} + +.sparkle--two { + top: 5px; + right: 75px; + font-size: 24px; +} + +.sparkle--three { + top: 65px; + right: 88px; + font-size: 16px; +} + +.unlock-card-wrap { + width: 100%; +} + +.unlock-card { + padding: 28px 30px 20px; + background: var(--surface-solid); + border: 1px solid rgba(224, 229, 239, 0.95); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); +} + +:root[data-theme="dark"] .unlock-card { + border-color: var(--line); +} + +.card-topline { + display: flex; + align-items: center; + gap: 13px; + margin-bottom: 24px; +} + +.card-key-icon { + display: grid; + width: 45px; + height: 45px; + place-items: center; + color: var(--blue); + background: var(--blue-soft); + border-radius: 14px; +} + +.card-kicker { + margin: 0 0 3px; + color: var(--muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.card-topline h2, +.modal-header h2 { + margin: 0; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-size: 21px; + font-weight: 800; + letter-spacing: -0.05em; +} + +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + padding: 3px; + margin-bottom: 22px; + background: var(--surface-muted); + border: 1px solid var(--line); + border-radius: 10px; +} + +.segmented-control button { + min-height: 31px; + color: var(--muted-strong); + background: transparent; + border-radius: 7px; + font-size: 11px; + font-weight: 700; +} + +.segmented-control button.active { + color: var(--ink); + background: var(--surface-solid); + box-shadow: var(--shadow-sm); +} + +.unlock-form, +.modal-form { + display: flex; + flex-direction: column; + gap: 15px; +} + +.field-label { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--ink-soft); + font-size: 11px; + font-weight: 700; +} + +.field-hint { + display: inline; + margin-left: 4px; + color: var(--muted); + font-size: 10px; + font-weight: 400; +} + +.text-input { + width: 100%; + min-height: 43px; + padding: 0 13px; + color: var(--ink); + background: var(--surface-muted); + border: 1px solid var(--line); + border-radius: 10px; + font-size: 12px; + transition: 160ms ease; +} + +.text-input::placeholder { + color: #aab2c1; +} + +.text-input:hover { + border-color: var(--line-strong); +} + +.text-input:focus { + background: var(--surface-solid); + border-color: var(--blue); + outline: none; + box-shadow: 0 0 0 3px rgba(82, 111, 232, 0.11); +} + +.text-input--master { + border-color: #b6c4fb; +} + +.password-input-wrap { + position: relative; + display: block; +} + +.password-input-wrap .text-input { + padding-right: 42px; +} + +.input-action { + position: absolute; + top: 50%; + right: 7px; + width: 30px; + height: 30px; + transform: translateY(-50%); +} + +.form-error { + padding: 10px 12px; + color: #b95261; + background: #fff0f2; + border: 1px solid #ffd6db; + border-radius: 9px; + font-size: 11px; + line-height: 1.4; +} + +:root[data-theme="dark"] .form-error { + color: #ffacb6; + background: #3a2028; + border-color: #65333d; +} + +.primary-button--full { + width: 100%; + margin-top: 2px; + min-height: 45px; +} + +.unlock-card-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding-top: 18px; + color: var(--muted); + font-size: 10px; +} + +.unlock-card-footer span:first-child { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.footer-separator, +.muted-dot { + color: var(--line-strong); +} + +.demo-strip { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 11px 13px; + background: rgba(239, 241, 255, 0.75); + border: 1px solid #dce1ff; + border-radius: 13px; +} + +:root[data-theme="dark"] .demo-strip { + background: #222748; + border-color: #343b68; +} + +.demo-strip-icon { + display: grid; + width: 29px; + height: 29px; + flex: 0 0 auto; + place-items: center; + color: var(--purple); + background: var(--surface-solid); + border-radius: 9px; +} + +.demo-strip > div:nth-child(2) { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.demo-strip strong { + color: var(--ink-soft); + font-size: 10px; +} + +.demo-strip span { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.demo-strip button { + padding: 5px 0 5px 7px; + color: var(--blue); + background: transparent; + font-size: 10px; + font-weight: 800; + white-space: nowrap; +} + +.unlock-footer { + display: flex; + justify-content: space-between; + padding-bottom: 25px; + color: var(--muted); + font-size: 10px; +} + +/* Vault shell */ +.vault-shell { + display: flex; + min-height: 100vh; + background: var(--page); +} + +.vault-sidebar { + display: flex; + width: 252px; + flex: 0 0 252px; + flex-direction: column; + padding: 26px 15px 17px; + background: var(--surface-solid); + border-right: 1px solid var(--line); +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px 32px; +} + +.sidebar-close, +.mobile-menu-button { + display: none; +} + +.sidebar-nav, +.sidebar-section { + display: flex; + flex-direction: column; + gap: 3px; +} + +.sidebar-section { + margin-top: 31px; +} + +.sidebar-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 13px 10px; + color: var(--muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.small-icon-button { + width: 23px; + height: 23px; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 11px; + width: 100%; + min-height: 39px; + padding: 0 13px; + color: var(--muted-strong); + text-align: left; + background: transparent; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + transition: 150ms ease; +} + +.sidebar-link svg { + color: var(--muted); + transition: 150ms ease; +} + +.sidebar-link:hover, +.sidebar-link--selected { + color: var(--ink); + background: var(--surface-hover); +} + +.sidebar-link--active { + color: var(--blue); + background: var(--blue-soft); +} + +.sidebar-link--active svg, +.sidebar-link--selected svg { + color: var(--blue); +} + +.sidebar-count { + margin-left: auto; + color: var(--muted); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.sidebar-spacer { + flex: 1; + min-height: 34px; +} + +.sidebar-security-card { + display: flex; + align-items: center; + gap: 9px; + margin: 0 4px 17px; + padding: 11px 10px; + background: var(--green-soft); + border: 1px solid rgba(82, 173, 131, 0.13); + border-radius: 12px; +} + +.security-card-orb { + width: 28px; + height: 28px; + border-radius: 9px; +} + +.sidebar-security-card > div:nth-child(2) { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.sidebar-security-card strong { + color: var(--ink-soft); + font-size: 10px; +} + +.sidebar-security-card span:not(.health-dot) { + color: var(--muted); + font-size: 9px; +} + +.sidebar-footer { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 13px; + border-top: 1px solid var(--line); +} + +.account-row { + display: flex; + align-items: center; + gap: 9px; + margin-top: 9px; + padding: 12px 9px 1px; +} + +.avatar { + display: grid; + width: 29px; + height: 29px; + flex: 0 0 auto; + place-items: center; + color: #596fe0; + background: #e8ecff; + border-radius: 10px; + font-size: 9px; + font-weight: 800; +} + +:root[data-theme="dark"] .avatar { + color: #c2cbff; + background: #323a69; +} + +.account-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.account-copy strong { + overflow: hidden; + color: var(--ink-soft); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-copy span { + overflow: hidden; + color: var(--muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-menu { + width: 27px; + height: 27px; +} + +.vault-main { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.vault-topbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 82px; + padding: 0 clamp(22px, 3vw, 43px); + border-bottom: 1px solid var(--line); +} + +.topbar-left, +.topbar-actions, +.breadcrumb, +.entries-tools, +.modal-actions, +.detail-topline, +.detail-value-row, +.value-actions { + display: flex; + align-items: center; +} + +.breadcrumb { + gap: 8px; + color: var(--muted); + font-size: 11px; +} + +.breadcrumb strong { + color: var(--ink-soft); + font-weight: 700; +} + +.topbar-actions { + gap: 9px; +} + +.search-box { + display: flex; + align-items: center; + gap: 8px; + width: min(245px, 27vw); + height: 35px; + padding: 0 10px; + color: var(--muted); + background: var(--surface-solid); + border: 1px solid var(--line); + border-radius: 9px; +} + +.search-box:focus-within { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(82, 111, 232, 0.1); +} + +.search-box input { + min-width: 0; + flex: 1; + color: var(--ink); + background: transparent; + border: 0; + outline: 0; + font-size: 11px; +} + +.search-box input::placeholder { + color: var(--muted); +} + +kbd { + padding: 2px 5px; + color: var(--muted); + background: var(--surface-muted); + border: 1px solid var(--line); + border-radius: 4px; + font-family: inherit; + font-size: 9px; + white-space: nowrap; +} + +.lock-button { + min-height: 35px; + padding-inline: 11px; + color: var(--muted-strong); + background: var(--surface-solid); + border: 1px solid var(--line); + font-size: 11px; +} + +.lock-button:hover { + color: var(--blue); + border-color: #bac6fa; +} + +.vault-content { + width: min(1380px, 100%); + flex: 1; + padding: 39px clamp(22px, 3vw, 43px) 42px; + margin-inline: auto; +} + +.vault-heading-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 27px; +} + +.vault-heading-row h1 { + margin: 12px 0 5px; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-size: clamp(28px, 3vw, 37px); + font-weight: 800; + letter-spacing: -0.075em; +} + +.section-lede { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.vault-insight-card { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 30px; + min-height: 104px; + padding: 18px 25px; + overflow: hidden; + color: #fff; + background: + radial-gradient(circle at 89% 30%, rgba(255, 255, 255, 0.2), transparent 14%), + linear-gradient(112deg, #526ee1 0%, #6675e5 52%, #8777e9 100%); + border-radius: 17px; + box-shadow: 0 12px 24px rgba(82, 111, 232, 0.17); +} + +.insight-copy, +.insight-copy > div:last-child, +.meter-label { + display: flex; + align-items: center; +} + +.insight-copy { + position: relative; + z-index: 1; + gap: 13px; +} + +.insight-icon { + display: grid; + width: 39px; + height: 39px; + place-items: center; + color: #fff; + background: rgba(255, 255, 255, 0.17); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; +} + +.insight-copy > div:last-child { + align-items: flex-start; + flex-direction: column; + gap: 2px; +} + +.insight-kicker { + color: rgba(255, 255, 255, 0.7); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.insight-copy strong { + font-family: "Manrope", sans-serif; + font-size: 14px; + letter-spacing: -0.03em; +} + +.insight-copy p { + margin: 0; + color: rgba(255, 255, 255, 0.73); + font-size: 10px; +} + +.insight-meter { + position: relative; + z-index: 1; + width: min(230px, 27%); +} + +.meter-label { + justify-content: space-between; + margin-bottom: 9px; + color: rgba(255, 255, 255, 0.69); + font-size: 9px; +} + +.meter-label strong { + color: #fff; + font-size: 10px; +} + +.meter-track { + height: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; +} + +.meter-track span { + display: block; + width: 91%; + height: 100%; + background: #fff; + border-radius: inherit; + box-shadow: 0 0 9px rgba(255, 255, 255, 0.45); +} + +.insight-decor { + position: absolute; + top: -51px; + right: 27%; + display: flex; + gap: 8px; + transform: rotate(28deg); + opacity: 0.13; +} + +.insight-decor span { + display: block; + width: 76px; + height: 170px; + border: 1px solid #fff; + border-radius: 50%; +} + +.content-columns { + display: grid; + grid-template-columns: minmax(460px, 1.06fr) minmax(330px, 0.74fr); + gap: 19px; + margin-top: 20px; +} + +.entries-card, +.detail-panel { + min-width: 0; + background: var(--surface-solid); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.entries-card { + display: flex; + min-height: 490px; + flex-direction: column; + overflow: hidden; +} + +.entries-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding: 21px 22px 16px; +} + +.entries-header h2 { + display: inline; + margin: 0 8px 0 0; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-size: 15px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.entries-header > div:first-child span { + color: var(--muted); + font-size: 10px; +} + +.entries-tools { + gap: 5px; +} + +.filter-button { + display: inline-flex; + align-items: center; + gap: 7px; + height: 31px; + padding: 0 9px; + color: var(--muted-strong); + background: var(--surface-muted); + border: 1px solid var(--line); + border-radius: 8px; + font-size: 10px; + font-weight: 600; +} + +.filter-button:hover { + color: var(--blue); +} + +.icon-button--card { + width: 31px; + height: 31px; + border: 1px solid var(--line); +} + +.entry-list { + display: flex; + flex-direction: column; + padding: 0 9px; +} + +.entry-row { + position: relative; + display: flex; + align-items: center; + gap: 11px; + width: 100%; + min-height: 66px; + padding: 9px 12px; + color: var(--ink-soft); + text-align: left; + background: transparent; + border-radius: 12px; + transition: 150ms ease; +} + +.entry-row:hover, +.entry-row--active { + background: var(--surface-muted); +} + +.entry-row--active::before { + position: absolute; + top: 16px; + bottom: 16px; + left: 0; + width: 3px; + content: ""; + background: var(--blue); + border-radius: 4px; +} + +.entry-logo, +.detail-logo { + display: grid; + flex: 0 0 auto; + place-items: center; + color: #fff; + background: linear-gradient(145deg, #7891ee, #5269d4); + border-radius: 12px; + font-family: "Manrope", sans-serif; + font-size: 10px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.entry-logo { + width: 37px; + height: 37px; +} + +.entry-logo--blue { + background: linear-gradient(145deg, #78a3f1, #5275d6); +} + +.entry-logo--violet { + background: linear-gradient(145deg, #b28df1, #7662d2); +} + +.entry-logo--mint { + background: linear-gradient(145deg, #75d7b9, #46a889); +} + +.entry-logo--orange { + background: linear-gradient(145deg, #ffb779, #e77e5c); +} + +.entry-logo--pink { + background: linear-gradient(145deg, #e995bd, #be658f); +} + +.entry-logo--indigo { + background: linear-gradient(145deg, #8695dd, #5e65b8); +} + +.entry-summary { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 4px; +} + +.entry-summary strong { + overflow: hidden; + color: var(--ink-soft); + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry-summary span { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry-meta { + display: flex; + flex: 0 0 111px; + flex-direction: column; + align-items: flex-end; + gap: 4px; + color: var(--muted); + font-size: 9px; +} + +.entry-category { + color: var(--muted-strong); + font-size: 9px; +} + +.entry-star { + flex: 0 0 auto; + color: #e6ae5e; +} + +.entry-chevron { + flex: 0 0 auto; + color: #b8bfcb; + transform: rotate(-90deg); +} + +.empty-state { + display: flex; + min-height: 300px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + color: var(--muted); + text-align: center; +} + +.empty-state-icon { + display: grid; + width: 42px; + height: 42px; + margin-bottom: 5px; + place-items: center; + color: var(--blue); + background: var(--blue-soft); + border-radius: 13px; +} + +.empty-state strong { + color: var(--ink-soft); + font-size: 12px; +} + +.empty-state span { + font-size: 10px; +} + +.entries-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: auto; + padding: 16px 22px; + color: var(--muted); + border-top: 1px solid var(--line); + font-size: 9px; +} + +.entries-footer span:first-child { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--green); + font-weight: 700; +} + +.detail-panel { + display: flex; + min-height: 490px; + flex-direction: column; + padding: 18px 22px 17px; +} + +.detail-topline { + justify-content: flex-end; + min-height: 31px; +} + +.mobile-detail-back { + display: none; + align-items: center; + gap: 5px; + margin-right: auto; + padding: 0; + color: var(--muted-strong); + background: transparent; + font-size: 11px; + font-weight: 700; +} + +.detail-hero { + display: flex; + align-items: center; + gap: 13px; + padding: 20px 3px 23px; +} + +.detail-logo { + width: 53px; + height: 53px; + border-radius: 17px; + font-size: 13px; +} + +.detail-title { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.detail-title > span { + color: var(--blue); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.detail-title h2 { + overflow: hidden; + margin: 0; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-size: 18px; + font-weight: 800; + letter-spacing: -0.05em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-title a { + display: inline-flex; + align-items: center; + gap: 4px; + overflow: hidden; + max-width: 200px; + color: var(--muted); + font-size: 10px; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-title a:hover { + color: var(--blue); +} + +.favorite-button { + width: 32px; + height: 32px; + color: var(--muted); +} + +.favorite-button--active { + color: #e6ae5e; + background: #fff7e8; +} + +:root[data-theme="dark"] .favorite-button--active { + background: #3c321d; +} + +.detail-divider { + height: 1px; + background: var(--line); +} + +.detail-fields { + display: flex; + flex-direction: column; + gap: 22px; + padding: 24px 3px 22px; +} + +.detail-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-label { + color: var(--muted); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.detail-value-row { + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.detail-value-row strong { + overflow: hidden; + color: var(--ink-soft); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.secret-text { + letter-spacing: 0.11em; +} + +.value-actions { + gap: 2px; +} + +.copy-button { + width: 28px; + height: 28px; +} + +.detail-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 11px; + color: var(--muted-strong); + background: var(--surface-muted); + border-radius: 9px; + font-size: 11px; + line-height: 1.4; +} + +.detail-note svg { + flex: 0 0 auto; + color: var(--blue); +} + +.detail-security-note { + display: flex; + align-items: center; + gap: 10px; + margin-top: auto; + padding: 12px; + background: var(--green-soft); + border: 1px solid rgba(82, 173, 131, 0.13); + border-radius: 11px; +} + +.security-note-icon { + display: grid; + width: 28px; + height: 28px; + flex: 0 0 auto; + place-items: center; + color: var(--green); + background: var(--surface-solid); + border-radius: 8px; +} + +.detail-security-note > div:last-child { + display: flex; + flex-direction: column; + gap: 2px; +} + +.detail-security-note strong { + color: var(--ink-soft); + font-size: 10px; +} + +.detail-security-note span { + color: var(--muted); + font-size: 9px; +} + +.detail-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 15px; + color: var(--muted); + font-size: 9px; +} + +.danger-button { + min-height: 28px; + padding: 0 8px; + color: var(--red); + background: transparent; + font-size: 10px; +} + +.danger-button:hover { + background: #fff0f2; +} + +:root[data-theme="dark"] .danger-button:hover { + background: #3a2028; +} + +/* Modal and feedback */ +.modal-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + padding: 20px; + place-items: center; + background: rgba(18, 25, 44, 0.35); + backdrop-filter: blur(8px); +} + +.modal-card { + width: min(100%, 500px); + max-height: calc(100vh - 40px); + overflow: auto; + padding: 25px; + background: var(--surface-solid); + border: 1px solid var(--line); + border-radius: 20px; + box-shadow: 0 28px 90px rgba(15, 22, 43, 0.2); +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 23px; +} + +.form-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 13px; +} + +select.text-input { + appearance: none; + cursor: pointer; +} + +.modal-actions { + justify-content: flex-end; + gap: 9px; + padding-top: 7px; +} + +.toast-message { + position: fixed; + z-index: 30; + right: 24px; + bottom: 24px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 14px; + color: #fff; + background: #202a40; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 11px; + box-shadow: 0 12px 28px rgba(15, 24, 48, 0.2); + font-size: 11px; + font-weight: 600; +} + +.toast-message svg { + color: #83d7ae; +} + +.toast-enter-active, +.toast-leave-active { + transition: 180ms ease; +} + +.toast-enter-from, +.toast-leave-to { + opacity: 0; + transform: translateY(8px); +} + +.mobile-backdrop { + display: none; +} + +@media (max-width: 1120px) { + .unlock-layout { + gap: 48px; + } + + .content-columns { + grid-template-columns: minmax(390px, 1fr) minmax(300px, 0.8fr); + } + + .entry-meta { + flex-basis: 88px; + } +} + +@media (max-width: 900px) { + .unlock-layout { + grid-template-columns: minmax(0, 0.92fr) minmax(340px, 0.9fr); + gap: 30px; + } + + .unlock-intro h1 { + font-size: clamp(37px, 5.5vw, 53px); + } + + .content-columns { + grid-template-columns: 1fr; + } + + .detail-panel { + min-height: 420px; + } +} + +@media (max-width: 760px) { + .shell-width { + width: min(100% - 36px, 560px); + } + + .unlock-nav { + padding-top: 20px; + } + + .status-pill { + display: none; + } + + .unlock-layout { + display: flex; + min-height: auto; + flex-direction: column; + align-items: stretch; + gap: 37px; + padding-block: 69px 42px; + } + + .unlock-intro { + max-width: none; + padding-left: 0; + } + + .unlock-intro h1 { + margin-top: 15px; + font-size: clamp(38px, 10vw, 52px); + } + + .unlock-lede { + max-width: 470px; + font-size: 14px; + } + + .trust-card { + margin-top: 27px; + } + + .unlock-sparkles { + display: none; + } + + .unlock-card { + padding: 24px 20px 18px; + } + + .unlock-footer { + gap: 8px; + padding-bottom: 19px; + font-size: 9px; + } + + .unlock-footer span:last-child { + text-align: right; + } + + .vault-sidebar { + position: fixed; + z-index: 15; + top: 0; + bottom: 0; + left: 0; + width: min(290px, calc(100vw - 55px)); + transform: translateX(-102%); + transition: transform 210ms ease; + box-shadow: 20px 0 45px rgba(20, 31, 59, 0.12); + } + + .vault-sidebar--open { + transform: translateX(0); + } + + .sidebar-close, + .mobile-menu-button { + display: inline-grid; + } + + .mobile-backdrop { + position: fixed; + z-index: 14; + inset: 0; + display: block; + background: rgba(18, 25, 44, 0.3); + backdrop-filter: blur(2px); + } + + .vault-topbar { + min-height: 67px; + padding-inline: 18px; + } + + .topbar-left { + gap: 6px; + } + + .topbar-actions { + gap: 5px; + } + + .search-box { + width: min(36vw, 190px); + } + + .search-box kbd { + display: none; + } + + .lock-button { + padding-inline: 8px; + } + + .vault-content { + padding: 28px 18px 30px; + } + + .vault-heading-row { + align-items: flex-start; + flex-direction: column; + gap: 17px; + margin-bottom: 21px; + } + + .vault-heading-row .primary-button { + width: 100%; + } + + .vault-insight-card { + align-items: flex-start; + flex-direction: column; + gap: 16px; + padding: 18px; + } + + .insight-meter { + width: 100%; + } + + .insight-decor { + top: -70px; + right: -30px; + } + + .content-columns { + display: block; + margin-top: 15px; + } + + .entries-card { + min-height: 430px; + } + + .entries-header { + padding: 18px 15px 13px; + } + + .entry-list { + padding-inline: 6px; + } + + .entry-row { + min-height: 62px; + padding-inline: 9px; + } + + .entry-meta { + display: none; + } + + .entry-star { + margin-left: auto; + } + + .entries-footer { + padding-inline: 16px; + } + + .detail-panel { + position: fixed; + z-index: 13; + inset: 0; + display: none; + min-height: 100vh; + overflow: auto; + padding: 18px 20px 23px; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .detail-panel--mobile-open { + display: flex; + } + + .mobile-detail-back { + display: inline-flex; + } + + .detail-hero { + padding-top: 35px; + } + + .detail-title a { + max-width: 220px; + } + + .modal-card { + padding: 21px 18px; + } +} + +@media (max-width: 420px) { + .unlock-intro h1 { + font-size: 37px; + } + + .unlock-footer span:first-child { + max-width: 170px; + } + + .unlock-footer span:last-child { + max-width: 120px; + } + + .topbar-actions .icon-button { + display: none; + } + + .search-box { + width: min(43vw, 170px); + } + + .breadcrumb span, + .breadcrumb svg { + display: none; + } + + .entries-tools .filter-button { + max-width: 115px; + } + + .entries-tools .filter-button span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .form-two-col { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/views/UnlockView.vue b/apps/web/src/views/UnlockView.vue new file mode 100644 index 0000000..145a0b4 --- /dev/null +++ b/apps/web/src/views/UnlockView.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/apps/web/src/views/VaultView.vue b/apps/web/src/views/VaultView.vue new file mode 100644 index 0000000..742cf43 --- /dev/null +++ b/apps/web/src/views/VaultView.vue @@ -0,0 +1,359 @@ + + + diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..52a7435 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "vue", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..f118939 --- /dev/null +++ b/apps/web/vite.config.ts @@ -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", + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d978b0 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..58b4967 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1525 @@ +{ + "name": "keychain-monorepo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keychain-monorepo", + "version": "0.1.0", + "workspaces": [ + "apps/web", + "apps/extension", + "packages/core" + ], + "engines": { + "node": ">=20" + } + }, + "apps/extension": { + "name": "@keychain/extension", + "version": "0.1.0", + "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" + } + }, + "apps/web": { + "name": "@keychain/web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" + }, + "node_modules/@keychain/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@keychain/extension": { + "resolved": "apps/extension", + "link": true + }, + "node_modules/@keychain/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/lucide-vue-next": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.468.0.tgz", + "integrity": "sha512-quV/6T8YB1XK0VOEnebg3Byd8Rsan5/m95cvjnuHV4vcS3qEnLAybkrSh0hk3ppavx+V7R1PjNW+mGDvcBdz4A==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "packages/core": { + "name": "@keychain/core", + "version": "0.1.0", + "dependencies": { + "hash-wasm": "^4.12.0" + }, + "devDependencies": { + "typescript": "^5.7.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c5504f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..d451a4d --- /dev/null +++ b/packages/core/package.json @@ -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" + } +} diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts new file mode 100644 index 0000000..74c58e8 --- /dev/null +++ b/packages/core/src/api.ts @@ -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(path: string, init: RequestInit = {}): Promise { + 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 { + const response = await this.request("/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 { + const response = await this.request("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + this.csrfToken = response.csrf_token; + return response.user; + } + + async me(): Promise { + return (await this.request<{ user: User }>("/auth/me")).user; + } + + async getVault(): Promise { + return this.request("/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 { + await this.request("/auth/logout", { method: "POST" }); + this.csrfToken = ""; + } +} diff --git a/packages/core/src/crypto.ts b/packages/core/src/crypto.ts new file mode 100644 index 0000000..e93d326 --- /dev/null +++ b/packages/core/src/crypto.ts @@ -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 { + 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 { + 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 { + 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"); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..cede460 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./crypto"; +export * from "./types"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..8e855fc --- /dev/null +++ b/packages/core/src/types.ts @@ -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; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..a1a8c06 --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}