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>
503 lines
19 KiB
Markdown
503 lines
19 KiB
Markdown
# 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<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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```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<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:
|
|
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": ["<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-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)
|