Files
relicario/docs/superpowers/specs/2026-04-12-relicario-wasm-extension-design.md
adlee-was-taken 519a6f0e36 chore: rename project from idfoto to relicario
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>
2026-04-19 16:47:02 -04:00

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-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

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:

  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

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

  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.

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:

// 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

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-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)