Sweeping rename across crates, CLI binary, WASM bindings, extension, docs,
and vault metadata paths. Git remote updated to relicario.git.
- crates/idfoto-{core,cli,wasm} -> crates/relicario-{core,cli,wasm}
- IdfotoError -> RelicarioError
- IDFOTO_IMAGE env var -> RELICARIO_IMAGE
- ~/.config/idfoto -> ~/.config/relicario
- .idfoto/ vault metadata dir -> .relicario/ (breaking; pre-release)
- Binary name idfoto -> relicario
- Extension wasm module idfoto_wasm -> relicario_wasm
- Storage key idfotoSettings -> relicarioSettings
- All doc filenames and content references updated
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
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-wasmcrate — wasm-bindgen wrapper aroundrelicario-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:
groupfield on entries for logical organization
Data Model Changes
Entry struct
pub struct Entry {
pub name: String,
pub url: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub notes: Option<String>,
pub totp_secret: Option<String>,
pub group: Option<String>, // NEW — None = ungrouped
pub created_at: String,
pub updated_at: String,
}
ManifestEntry struct
pub struct ManifestEntry {
pub name: String,
pub url: Option<String>,
pub username: Option<String>,
pub group: Option<String>, // 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
// KDF + crypto
#[wasm_bindgen]
pub fn derive_master_key(passphrase: &str, image_secret: &[u8], salt: &[u8], params_json: &str) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue>
// Image secret extraction
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue>
// Vault operations (convenience wrappers — JSON in, encrypted bytes out)
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue>
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue>
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue>
// TOTP — RFC 6238, HMAC-SHA1, 6-digit codes, 30-second step
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue>
// Utilities
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String
#[wasm_bindgen]
pub fn generate_entry_id() -> String
Dependencies
[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
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:
- Base32-decode the secret
- Compute time step:
counter = timestamp_secs / 30 - HMAC-SHA1(secret, counter as big-endian u64)
- Dynamic truncation → 6-digit code
- 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
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:
// 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
- User enters passphrase in popup
- Popup sends
{ type: "unlock", passphrase }to service worker - Service worker loads vault config from
chrome.storage.local(includes image bytes) - WASM:
extract_image_secret(image_bytes)→image_secret - Service worker fetches
.relicario/saltand.relicario/params.jsonvia git API - WASM:
derive_master_key(passphrase, image_secret, salt, params)→master_key - Service worker fetches
manifest.encvia git API - WASM:
decrypt_manifest(manifest_enc, master_key)→ manifest - Cache
master_keyandmanifestin worker memory - 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.
interface GitHost {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
deleteFile(path: string, message: string): Promise<void>;
listDir(path: string): Promise<string[]>;
}
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:
- Git host config: host type toggle (Gitea/GitHub), host URL, repo path, API token
- Reference image: file upload (drag-and-drop or file picker), stored to
chrome.storage.local - 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:
// 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):
- Service worker sends
{ username, password }to content script - Content script sets
.valueon detected fields - Dispatches
inputandchangeevents (required for React/Vue/Angular controlled inputs) - 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
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
Extension build
cd extension && npm run build # TypeScript → bundled JS via webpack/vite → dist/
Combined
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
{
"manifest_version": 3,
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"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": ["<all_urls>"],
"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: ["<all_urls>"] 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-coreoutput
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)