# Relicario — WASM + Chrome MV3 Extension Design The browser extension for Relicario. Compiles `relicario-core` to WASM, wraps it in a Chrome MV3 extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. No CLI dependency, no native messaging bridge. ## Scope - `relicario-wasm` crate — wasm-bindgen wrapper around `relicario-core` - Chrome MV3 extension: - One-time setup wizard (git host + token + repo + reference image) - Service worker — WASM runtime, master_key holder, vault operations, git API - Popup — unlock, search/list, group filtering, entry detail, TOTP countdown, keyboard-first - Content script — conservative login form detection, explicit-trigger autofill - Data model addition: `group` field on entries for logical organization ## Data Model Changes ### Entry struct ```rust pub struct Entry { pub name: String, pub url: Option, pub username: Option, pub password: Option, pub notes: Option, pub totp_secret: Option, pub group: Option, // NEW — None = ungrouped pub created_at: String, pub updated_at: String, } ``` ### ManifestEntry struct ```rust pub struct ManifestEntry { pub name: String, pub url: Option, pub username: Option, pub group: Option, // NEW — for popup filtering without decrypting entries pub updated_at: String, } ``` The `group` field is a free-form string. No predefined list, no nesting. User types "work" or "family" and entries cluster. Backwards-compatible — existing vaults without `group` deserialize as `None` (ungrouped). ## WASM Crate (`relicario-wasm`) Thin wasm-bindgen wrapper exposing `relicario-core` functions to JavaScript. Lives at `crates/relicario-wasm/`. ### Public API ```rust // KDF + crypto #[wasm_bindgen] pub fn derive_master_key(passphrase: &str, image_secret: &[u8], salt: &[u8], params_json: &str) -> Result, JsValue> #[wasm_bindgen] pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result, JsValue> #[wasm_bindgen] pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result, JsValue> // Image secret extraction #[wasm_bindgen] pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result, JsValue> // Vault operations (convenience wrappers — JSON in, encrypted bytes out) #[wasm_bindgen] pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result, JsValue> #[wasm_bindgen] pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result #[wasm_bindgen] pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result, JsValue> #[wasm_bindgen] pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result // TOTP — RFC 6238, HMAC-SHA1, 6-digit codes, 30-second step #[wasm_bindgen] pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result // Utilities #[wasm_bindgen] pub fn generate_password(length: u32) -> String #[wasm_bindgen] pub fn generate_entry_id() -> String ``` ### Dependencies ```toml [dependencies] relicario-core = { path = "../relicario-core" } wasm-bindgen = "0.2" js-sys = "0.3" serde_json = "1" hmac = "0.12" sha1 = "0.10" # TOTP requires HMAC-SHA1 per RFC 6238 data-encoding = "2" # base32 decoding for TOTP secrets ``` ### WASM build ```bash wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm ``` Output: `relicario_wasm.js` (JS glue) + `relicario_wasm_bg.wasm` (binary). Expected size ~200-500 KB gzipped. The `image` crate's JPEG decoder is the heaviest component — optimize only if measured size is a problem. ### TOTP implementation Standard RFC 6238: 1. Base32-decode the secret 2. Compute time step: `counter = timestamp_secs / 30` 3. HMAC-SHA1(secret, counter as big-endian u64) 4. Dynamic truncation → 6-digit code 5. Zero-pad to 6 digits Implemented in the WASM crate, not in JavaScript. No JS crypto dependency. ## Extension Architecture ### Approach: Monolith Service Worker All logic lives in the service worker. Popup and content script are thin UI/DOM layers that communicate via `chrome.runtime.sendMessage`. The master_key exists only in the service worker's memory. Chrome MV3 may terminate idle service workers after ~30 seconds — this clears the key and requires re-unlock. This is a feature: natural session timeout with zero additional code. Mitigations for premature termination: - Chrome keeps workers alive while message ports are open (popup open = worker alive) - Content scripts can send periodic keepalive pings on active tabs - Re-unlock is fast enough (~1-2s for Argon2id in WASM) that it's not painful ### Service Worker State ```typescript interface WorkerState { masterKey: Uint8Array | null; // held in memory after unlock, cleared on termination manifest: Manifest | null; // cached after first decrypt, refreshed on sync config: VaultConfig | null; // from chrome.storage.local } interface VaultConfig { hostType: "gitea" | "github"; hostUrl: string; // e.g. "https://git.adlee.work" repoPath: string; // e.g. "alee/relicario-vault" apiToken: string; // personal access token imageBytes: Uint8Array; // reference JPEG, stored in chrome.storage.local } ``` ### Message API Popup and content script communicate with the service worker via typed messages: ```typescript // Auth { type: "unlock", passphrase: string } → { ok: true } | { error: string } { type: "lock" } → { ok: true } { type: "is_unlocked" } → { unlocked: boolean } // Vault reads { type: "list_entries", group?: string } → ManifestEntry[] { type: "get_entry", id: string } → Entry { type: "search_entries", query: string } → ManifestEntry[] // Vault writes { type: "add_entry", entry: EntryInput } → { id: string } { type: "update_entry", id: string, entry: EntryInput } → { ok: true } { type: "delete_entry", id: string } → { ok: true } // TOTP { type: "get_totp", id: string } → { code: string, remaining_seconds: number } // Autofill { type: "get_autofill_candidates", url: string } → ManifestEntry[] { type: "get_credentials", id: string } → { username: string, password: string } // Sync { type: "sync" } → { ok: true } | { error: string } ``` ### Unlock Flow 1. User enters passphrase in popup 2. Popup sends `{ type: "unlock", passphrase }` to service worker 3. Service worker loads vault config from `chrome.storage.local` (includes image bytes) 4. WASM: `extract_image_secret(image_bytes)` → `image_secret` 5. Service worker fetches `.relicario/salt` and `.relicario/params.json` via git API 6. WASM: `derive_master_key(passphrase, image_secret, salt, params)` → `master_key` 7. Service worker fetches `manifest.enc` via git API 8. WASM: `decrypt_manifest(manifest_enc, master_key)` → manifest 9. Cache `master_key` and `manifest` in worker memory 10. Reply `{ ok: true }` to popup Steps 4-6 take ~1-2 seconds (Argon2id dominates). Popup shows a spinner. ## Git API Layer Abstracts Gitea and GitHub behind a common interface. Both use nearly identical REST APIs for file CRUD. ```typescript interface GitHost { readFile(path: string): Promise; writeFile(path: string, content: Uint8Array, message: string): Promise; deleteFile(path: string, message: string): Promise; listDir(path: string): Promise; } ``` ### GiteaHost - Base: `{hostUrl}/api/v1/repos/{repoPath}/contents/{path}` - Auth: `Authorization: token {apiToken}` - File content returned as base64 in JSON response - Write/delete requires the file's SHA (fetched first, then sent with the update) ### GitHubHost - Base: `https://api.github.com/repos/{repoPath}/contents/{path}` - Auth: `Authorization: Bearer {apiToken}` - Same base64 content model, same SHA requirement for updates ### Sync behavior - On unlock: fetch salt, params, manifest - On entry access: fetch individual entry file on demand - On write (add/edit/rm): two sequential API commits — entry file first, then updated manifest - Each API call = one commit. A write operation is two commits (entry + manifest), linear history - No branching, no merging, no conflict resolution in V1 - If the remote has changed since last read (SHA mismatch on write), the API returns 409 — surface the error, user re-syncs ## Popup UI ### Design Language - **Theme:** Dark background (#0d1117), monospace typography (system monospace stack, JetBrains Mono preferred) - **Aesthetic:** Terminal/dev tool feel. Minimal chrome, tight spacing, no rounded corners beyond 2px - **Colors:** Blue (#58a6ff) for interactive elements and branding, green (#3fb950) for TOTP codes, muted gray (#8b949e) for secondary text, dark surfaces (#161b22) for inputs - **Interactions:** Keyboard-first. Every action has a single-key shortcut. Mouse works but isn't required. ### Popup States The popup is a state machine with four primary states: **1. Locked (unlock prompt)** - Single passphrase input field - ENTER to submit, ESC to close popup - Spinner during Argon2id derivation - Error message on bad passphrase (inline, red text) **2. Entry List** - Search bar at top (focused by `/`) - Group filter tabs below search (all, personal, work, etc. — derived from entries) - Scrollable entry list with keyboard navigation (↑↓) - Each entry shows: name, username, domain (extracted from URL) - Active entry highlighted with left blue border - Footer: keybinding hints - `+` to add new entry - ENTER to open selected entry **3. Entry Detail** - Back navigation (ESC) - Entry name as header, group label - Fields: URL, username (c to copy), password masked (p to copy), TOTP code with countdown bar (t to copy) - Notes section (if present) - Actions: f = autofill active tab, e = edit, d = delete (with confirmation) - TOTP countdown: green progress bar, updates every second, code regenerates at 0 **4. Setup Wizard** - Three steps with progress bar: 1. Git host config: host type toggle (Gitea/GitHub), host URL, repo path, API token 2. Reference image: file upload (drag-and-drop or file picker), stored to `chrome.storage.local` 3. Test unlock: enter passphrase, verify derivation succeeds against the remote vault - Back/next navigation, validation on each step ### Additional Views (modal overlays) - **Add/Edit Entry:** Form with fields for name, URL, username, password (with generate button), TOTP secret, group, notes. Save commits to git. - **Delete Confirmation:** "Delete {name}? This commits a removal to the vault." Yes/No. ### Keyboard Shortcuts | Key | Context | Action | |-----|---------|--------| | `/` | List | Focus search | | `↑↓` | List | Navigate entries | | `Enter` | List | Open selected entry | | `Esc` | Detail/Edit | Back to list | | `Esc` | List | Close popup | | `+` | List | Add new entry | | `c` | Detail | Copy username | | `p` | Detail | Copy password | | `t` | Detail | Copy TOTP code | | `f` | Detail | Autofill active tab | | `e` | Detail | Edit entry | | `d` | Detail | Delete entry (with confirmation) | ### Popup Dimensions Width: 360px. Height: auto, max 500px with scroll. Standard Chrome extension popup constraints. ## Content Script Runs on all HTTP/HTTPS pages. Three responsibilities: ### 1. Login Form Detection Conservative detection — standard selectors only: ```typescript // Password field detection const passwordFields = document.querySelectorAll('input[type="password"]'); // Username field detection (adjacent to password field) // Priority order: // 1. input[autocomplete="username"] // 2. input[autocomplete="email"] // 3. input[type="email"] // 4. input[name] matching /user|email|login|account/i // 5. Nearest preceding text/email input in the same form ``` No shadow DOM traversal. No heuristic scoring. No iframe inspection. If the form uses non-standard markup, the user copies from the popup manually. ### 2. Field Icon Injection When a password field is detected: - Small Relicario icon (16x16, inline SVG) appears at the right edge of the password field - Click triggers: send page URL to service worker → get matching entries - Single match: fill immediately - Multiple matches: show inline picker (small dropdown below the icon) - Icon styled to not conflict with existing field content ### 3. Credential Fill On fill trigger (from popup `f` key or field icon click): 1. Service worker sends `{ username, password }` to content script 2. Content script sets `.value` on detected fields 3. Dispatches `input` and `change` events (required for React/Vue/Angular controlled inputs) 4. Focuses the next logical element (submit button or next field) The content script never receives the master_key, manifest, or any vault data beyond the specific credentials being filled. ## Extension File Structure ``` extension/ ├── manifest.json # MV3 manifest ├── package.json # TypeScript, build tooling ├── tsconfig.json ├── webpack.config.js # or vite.config.ts ├── src/ │ ├── service-worker/ │ │ ├── index.ts # WASM init, message router, state management │ │ ├── vault.ts # vault CRUD operations │ │ ├── git-host.ts # GitHost interface definition │ │ ├── gitea.ts # Gitea API implementation │ │ ├── github.ts # GitHub API implementation │ │ ├── totp.ts # TOTP code request handling │ │ └── autofill.ts # content script coordination │ ├── popup/ │ │ ├── index.html # popup shell │ │ ├── popup.ts # state machine: locked → list → detail → edit │ │ ├── components/ │ │ │ ├── unlock.ts # passphrase prompt │ │ │ ├── entry-list.ts # search + group filter + entry rows │ │ │ ├── entry-detail.ts # field display + TOTP countdown │ │ │ ├── entry-form.ts # add/edit form │ │ │ └── setup-wizard.ts # three-step setup flow │ │ └── styles.css # terminal dark theme │ ├── content/ │ │ ├── detector.ts # login form field detection │ │ ├── fill.ts # credential injection + event dispatch │ │ └── icon.ts # field icon injection + inline picker │ └── shared/ │ ├── messages.ts # typed message definitions │ └── types.ts # Entry, ManifestEntry, VaultConfig, etc. ├── wasm/ # wasm-pack output (relicario_wasm.js + .wasm) ├── icons/ # extension icons (16, 48, 128px) └── dist/ # build output → load unpacked into Chrome ``` No framework. Vanilla TypeScript + DOM manipulation. The popup is small enough that a framework adds overhead without value. Bundle stays tiny. ## Build Pipeline ### WASM build ```bash wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm ``` ### Extension build ```bash cd extension && npm run build # TypeScript → bundled JS via webpack/vite → dist/ ``` ### Combined ```bash make extension # or: npm run build:all from extension/ ``` Chains wasm-pack then webpack. Dev mode: `npm run dev` watches TypeScript and auto-rebuilds. WASM only needs rebuild when Rust source changes. ### Chrome manifest.json ```json { "manifest_version": 3, "name": "relicario", "version": "0.1.0", "description": "Two-factor encrypted password manager", "permissions": ["storage", "activeTab", "clipboardWrite"], "host_permissions": [""], "background": { "service_worker": "service-worker.js", "type": "module" }, "action": { "default_popup": "popup.html", "default_icon": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" } }, "content_scripts": [{ "matches": [""], "js": ["content.js"], "run_at": "document_idle" }], "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" } } ``` Note: `wasm-unsafe-eval` is required in MV3 to instantiate WASM modules. This is the standard approach — Chrome explicitly added this directive for WASM use cases. `host_permissions: [""]` is needed for the content script to run on all pages and for the service worker to make API calls to arbitrary git hosts. ## Security Considerations ### What's stored in `chrome.storage.local` | Data | Sensitivity | Rationale | |------|-------------|-----------| | Reference image bytes | Low | Public in threat model (can live on social media). Provides image_secret but useless without passphrase. | | API token | Medium | Grants repo access. Scoped to repo-only permissions. | | Host URL, repo path | Low | Not secret. | ### What's never persisted - Passphrase - master_key (service worker memory only, cleared on termination) - image_secret (derived in memory during unlock, not cached) ### Content script isolation The content script runs in the page's DOM context but never receives vault-level data. It only gets the specific `{ username, password }` pair for a fill operation, delivered on demand by the service worker. ### API token security The token is stored in `chrome.storage.local`, which is sandboxed per-extension and inaccessible to web pages. A compromised extension could leak it, but that's true of any credential stored by any extension. Mitigation: scope the token to minimum required permissions (repo read/write only). ## Testing Strategy ### WASM crate - Unit tests: each wrapper function round-trips correctly (`wasm-pack test --node`) - TOTP: test vectors from RFC 6238 appendix B - Integration: derive key + encrypt + decrypt cycle matches `relicario-core` output ### Extension (manual for V1) - Setup wizard: configure Gitea host, upload reference image, test unlock - CRUD: add, view, edit, delete entries through popup - Groups: create entries in different groups, verify filter works - Autofill: test on standard login forms (GitHub, Google, etc.) - TOTP: verify generated codes match Google Authenticator for same seed - Service worker lifecycle: close popup, wait >30s, reopen — verify re-unlock required - Offline: verify graceful error when git host unreachable ### Future: automated extension testing with Puppeteer/Playwright Not in V1 scope. The extension is small enough that manual testing covers it. ## Non-Goals - Firefox/Safari extensions (later plan) - Offline vault cache (extension always needs git host access) - Conflict resolution (409 on write = re-sync, no merge) - Framework (React, Vue, etc.) for popup UI - Automated E2E testing (manual for V1) - Multiple vaults per extension (single vault, groups for organization)