Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3291 lines
103 KiB
Markdown
3291 lines
103 KiB
Markdown
# Relicario WASM + Chrome MV3 Extension Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Compile `relicario-core` to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access.
|
|
|
|
**Architecture:** Monolith service worker loads the WASM module and holds all state (master_key, cached manifest). Popup and content script are thin UI layers communicating via `chrome.runtime.sendMessage`. Vault data is fetched/committed directly via Gitea/GitHub REST APIs — no local clone, no CLI dependency.
|
|
|
|
**Tech Stack:** Rust + wasm-bindgen (WASM crate), TypeScript + webpack (extension), Chrome MV3 APIs
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-wasm-extension-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Rust (new crate)
|
|
|
|
```
|
|
crates/relicario-wasm/
|
|
├── Cargo.toml
|
|
└── src/
|
|
└── lib.rs # wasm-bindgen wrappers + TOTP implementation
|
|
```
|
|
|
|
### Rust (modified)
|
|
|
|
```
|
|
crates/relicario-core/src/entry.rs # Add group field to Entry and ManifestEntry
|
|
Cargo.toml # Add relicario-wasm to workspace members
|
|
```
|
|
|
|
### Extension (all new)
|
|
|
|
```
|
|
extension/
|
|
├── manifest.json
|
|
├── package.json
|
|
├── tsconfig.json
|
|
├── webpack.config.js
|
|
├── src/
|
|
│ ├── service-worker/
|
|
│ │ ├── index.ts # WASM init, message router, state
|
|
│ │ ├── vault.ts # vault CRUD operations
|
|
│ │ ├── git-host.ts # GitHost interface + factory
|
|
│ │ ├── gitea.ts # Gitea API implementation
|
|
│ │ └── github.ts # GitHub API implementation
|
|
│ ├── popup/
|
|
│ │ ├── index.html # popup HTML shell
|
|
│ │ ├── popup.ts # state machine: setup → locked → list → detail → edit
|
|
│ │ ├── styles.css # terminal dark theme
|
|
│ │ └── 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 # 3-step setup flow
|
|
│ ├── content/
|
|
│ │ ├── detector.ts # login form detection
|
|
│ │ ├── fill.ts # credential injection
|
|
│ │ └── icon.ts # field icon injection + inline picker
|
|
│ └── shared/
|
|
│ ├── messages.ts # typed message definitions
|
|
│ └── types.ts # Entry, ManifestEntry, VaultConfig types
|
|
└── icons/
|
|
├── icon-16.png
|
|
├── icon-48.png
|
|
└── icon-128.png
|
|
```
|
|
|
|
---
|
|
|
|
## Task 0: Add Heavy Comments to Existing Rust Code
|
|
|
|
**Files:**
|
|
- Modify: `crates/relicario-core/src/lib.rs`
|
|
- Modify: `crates/relicario-core/src/error.rs`
|
|
- Modify: `crates/relicario-core/src/crypto.rs`
|
|
- Modify: `crates/relicario-core/src/entry.rs`
|
|
- Modify: `crates/relicario-core/src/vault.rs`
|
|
- Modify: `crates/relicario-core/src/imgsecret.rs`
|
|
- Modify: `crates/relicario-cli/src/main.rs`
|
|
|
|
Add thorough documentation comments to all existing Rust code. Every public function, struct, field, constant, and non-trivial private function should have doc comments explaining what it does, why it exists, and any important constraints. Module-level docs should explain the module's role in the overall architecture.
|
|
|
|
Guidelines:
|
|
- Use `///` for public items and `//` for inline explanations of non-obvious logic
|
|
- Explain the "why" not just the "what" — e.g., why XChaCha20 over AES-GCM, why mid-frequency DCT coefficients, why majority voting
|
|
- Reference the crypto pipeline and threat model from the design spec where relevant
|
|
- Document parameters, return values, and error conditions
|
|
- For `imgsecret.rs`, explain the steganography algorithm step by step — this is the novel component and should read like a tutorial
|
|
- For `crypto.rs`, document the binary format and why each field exists
|
|
- For `entry.rs`, document the vault data model and serialization strategy
|
|
- For `main.rs`, document the CLI flow and unlock sequence
|
|
|
|
- [ ] **Step 1: Add module-level docs and comments to `lib.rs`**
|
|
|
|
```rust
|
|
//! # relicario-core
|
|
//!
|
|
//! Platform-agnostic core library for the relicario password manager.
|
|
//!
|
|
//! This crate is deliberately bytes-in/bytes-out — no filesystem, no network,
|
|
//! no git operations. This makes it portable to WASM (browser extension),
|
|
//! Android (JNI), and iOS (Swift bridge) without modification.
|
|
//!
|
|
//! ## Modules
|
|
//!
|
|
//! - [`crypto`] — Argon2id key derivation + XChaCha20-Poly1305 authenticated encryption
|
|
//! - [`entry`] — Entry, ManifestEntry, and Manifest data structures
|
|
//! - [`vault`] — High-level encrypt/decrypt operations for entries and manifests
|
|
//! - [`imgsecret`] — DCT-based 256-bit secret embedding in JPEG images
|
|
//! - [`error`] — Error types for all operations
|
|
```
|
|
|
|
- [ ] **Step 2: Add comments to `error.rs`**
|
|
|
|
Document each error variant with when it occurs and what the caller should do about it.
|
|
|
|
- [ ] **Step 3: Add comments to `crypto.rs`**
|
|
|
|
Document:
|
|
- The binary ciphertext format (version byte + nonce + ciphertext + tag) and why each field exists
|
|
- Why XChaCha20-Poly1305 was chosen over AES-GCM (192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI)
|
|
- The KDF pipeline: how passphrase + image_secret are concatenated as the Argon2id password input
|
|
- KdfParams and why they're configurable (future parameter upgrades, different hardware profiles)
|
|
- Constants and their significance (VERSION_BYTE, NONCE_LEN, TAG_LEN, HEADER_LEN)
|
|
|
|
- [ ] **Step 4: Add comments to `entry.rs`**
|
|
|
|
Document:
|
|
- The vault data model: Entry (full credential), ManifestEntry (index metadata), Manifest (entry index)
|
|
- Why the manifest exists (decrypt one file to list/search entries without decrypting every entry)
|
|
- `skip_serializing_if` strategy for optional fields (backwards compatibility, compact JSON)
|
|
- Entry ID format (random 8-char hex, 32 bits — sufficient for family vault sizes)
|
|
- The search implementation and its case-insensitive substring matching
|
|
|
|
- [ ] **Step 5: Add comments to `vault.rs`**
|
|
|
|
Document:
|
|
- The relationship between vault.rs and crypto.rs (vault = typed wrappers around raw encrypt/decrypt)
|
|
- The serialization path: struct → JSON → encrypt → ciphertext bytes (and reverse)
|
|
- Why a single master_key is used for both entries and manifest (simpler, sufficient for family vault sizes)
|
|
|
|
- [ ] **Step 6: Add comments to `imgsecret.rs`**
|
|
|
|
This is the technically novel component. Document heavily:
|
|
- Module-level doc explaining the DCT steganography approach and why it was chosen
|
|
- Constants: BLOCK_SIZE, QUANT_STEP (why 50.0 — higher than typical 25 to survive JPEG recompression), MIN_DIMENSION, SECRET_BITS, MIN_COPIES, BLOCKS_PER_COPY, EMBED_POSITIONS (mid-frequency DCT coefficients in zig-zag order)
|
|
- YChannel: what it represents, why only luminance (survives re-encoding best, Cb/Cr often subsampled)
|
|
- EmbedRegion: the central 70% concept, the 15% crumple zone for crop tolerance
|
|
- DCT functions: what DCT is, why 8x8 blocks (matches JPEG's own block size), the 2D DCT as row-then-column 1D DCTs
|
|
- QIM (Quantization Index Modulation): how it encodes bits by rounding to quantization grids, why it's robust to re-quantization
|
|
- The embedding process step by step: decode → extract Y → define region → block DCT → select blocks → encode with redundancy → QIM embed → reconstruct
|
|
- The extraction process: canonical try → crop recovery search → majority voting with confidence threshold
|
|
- Bit conversion helpers
|
|
- Block selection strategy (evenly spaced, fixed geometric pattern)
|
|
- The reconstruct_jpeg function and the YCbCr ↔ RGB color space conversion
|
|
|
|
- [ ] **Step 7: Add comments to `main.rs` (CLI)**
|
|
|
|
Document:
|
|
- Module-level doc explaining this is the platform layer (filesystem, git, terminal I/O)
|
|
- The unlock flow: passphrase prompt → read reference image → extract image_secret → read salt/params → derive master_key
|
|
- Each CLI command's purpose and flow
|
|
- Helper functions: why git_commit uses `git add -A`, why generate_password uses OsRng, why now_iso8601 uses UNIX epoch seconds
|
|
- Device management: ed25519 key generation, why device keys are separate from the KDF
|
|
|
|
- [ ] **Step 8: Run tests to verify comments didn't break anything**
|
|
|
|
Run: `cargo test`
|
|
Expected: All tests pass unchanged.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add crates/relicario-core/src/ crates/relicario-cli/src/main.rs
|
|
git commit -m "docs: add heavy documentation comments to all Rust code"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Add `group` Field to Core Data Model
|
|
|
|
**Files:**
|
|
- Modify: `crates/relicario-core/src/entry.rs`
|
|
- Modify: `crates/relicario-core/src/vault.rs` (test helpers)
|
|
- Modify: `crates/relicario-cli/src/main.rs` (Entry construction sites)
|
|
- Test: `crates/relicario-core/src/entry.rs` (inline tests)
|
|
|
|
- [ ] **Step 1: Add `group` field to `Entry` struct**
|
|
|
|
In `crates/relicario-core/src/entry.rs`, add the field after `totp_secret`:
|
|
|
|
```rust
|
|
pub struct Entry {
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
pub password: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub notes: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub totp_secret: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub group: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add `group` field to `ManifestEntry` struct**
|
|
|
|
In the same file:
|
|
|
|
```rust
|
|
pub struct ManifestEntry {
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub group: Option<String>,
|
|
pub updated_at: String,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Fix all Entry construction sites in tests and CLI**
|
|
|
|
Update every place that constructs `Entry` or `ManifestEntry` to include `group: None`. These are:
|
|
|
|
In `crates/relicario-core/src/entry.rs` tests — `entry_serialization_round_trip`, `manifest_add_and_lookup`, `manifest_serialization_round_trip`, `manifest_search_case_insensitive`:
|
|
|
|
```rust
|
|
// Every Entry construction gets:
|
|
group: None,
|
|
|
|
// Every ManifestEntry construction gets:
|
|
group: None,
|
|
```
|
|
|
|
In `crates/relicario-core/src/vault.rs` tests — `sample_entry()` helper and `manifest_encrypt_decrypt_round_trip`:
|
|
|
|
```rust
|
|
// sample_entry() gets:
|
|
group: None,
|
|
|
|
// ManifestEntry in manifest_encrypt_decrypt_round_trip gets:
|
|
group: None,
|
|
```
|
|
|
|
In `crates/relicario-core/tests/integration.rs` — `full_vault_workflow()` Entry construction (line ~55) and ManifestEntry (line ~101):
|
|
|
|
```rust
|
|
// Entry construction gets:
|
|
group: None,
|
|
|
|
// ManifestEntry construction gets:
|
|
group: None,
|
|
```
|
|
|
|
In `crates/relicario-cli/src/main.rs` — `cmd_add()` Entry construction (line ~328), `cmd_add()` ManifestEntry (line ~349), `cmd_edit()` Entry construction (line ~513), `cmd_edit()` ManifestEntry (line ~536):
|
|
|
|
```rust
|
|
// Every Entry construction gets:
|
|
group: None,
|
|
|
|
// Every ManifestEntry construction gets:
|
|
group: None,
|
|
```
|
|
|
|
- [ ] **Step 4: Add a test for backwards compatibility (deserialize without group)**
|
|
|
|
In `crates/relicario-core/src/entry.rs` tests:
|
|
|
|
```rust
|
|
#[test]
|
|
fn entry_deserializes_without_group_field() {
|
|
let json = r#"{
|
|
"name": "Legacy",
|
|
"password": "old-pw",
|
|
"created_at": "2024-01-01T00:00:00Z",
|
|
"updated_at": "2024-01-01T00:00:00Z"
|
|
}"#;
|
|
let entry: Entry = serde_json::from_str(json).unwrap();
|
|
assert_eq!(entry.name, "Legacy");
|
|
assert_eq!(entry.group, None);
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_entry_deserializes_without_group_field() {
|
|
let json = r#"{
|
|
"name": "Legacy",
|
|
"updated_at": "2024-01-01T00:00:00Z"
|
|
}"#;
|
|
let entry: ManifestEntry = serde_json::from_str(json).unwrap();
|
|
assert_eq!(entry.name, "Legacy");
|
|
assert_eq!(entry.group, None);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_with_group_round_trips() {
|
|
let entry = Entry {
|
|
name: "Work GitHub".to_string(),
|
|
url: Some("https://github.com".to_string()),
|
|
username: Some("alee-work".to_string()),
|
|
password: "s3cr3t".to_string(),
|
|
notes: None,
|
|
totp_secret: None,
|
|
group: Some("work".to_string()),
|
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
assert!(json.contains("\"group\":\"work\""));
|
|
let decoded: Entry = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(decoded.group, Some("work".to_string()));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run all tests**
|
|
|
|
Run: `cargo test`
|
|
Expected: All tests pass, including new backwards-compatibility tests.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/relicario-core/src/entry.rs crates/relicario-core/src/vault.rs crates/relicario-core/tests/integration.rs crates/relicario-cli/src/main.rs
|
|
git commit -m "feat: add group field to Entry and ManifestEntry"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create `relicario-wasm` Crate
|
|
|
|
**Files:**
|
|
- Create: `crates/relicario-wasm/Cargo.toml`
|
|
- Create: `crates/relicario-wasm/src/lib.rs`
|
|
- Modify: `Cargo.toml` (workspace members)
|
|
|
|
- [ ] **Step 1: Create Cargo.toml**
|
|
|
|
Create `crates/relicario-wasm/Cargo.toml`:
|
|
|
|
```toml
|
|
[package]
|
|
name = "relicario-wasm"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
description = "WASM bindings for relicario password manager"
|
|
|
|
[lib]
|
|
crate-type = ["cdylib", "rlib"]
|
|
|
|
[dependencies]
|
|
relicario-core = { path = "../relicario-core" }
|
|
wasm-bindgen = "0.2"
|
|
js-sys = "0.3"
|
|
serde_json = "1"
|
|
hmac = "0.12"
|
|
sha1 = "0.10"
|
|
data-encoding = "2"
|
|
|
|
[dev-dependencies]
|
|
wasm-bindgen-test = "0.3"
|
|
```
|
|
|
|
- [ ] **Step 2: Add to workspace**
|
|
|
|
In root `Cargo.toml`, add `"crates/relicario-wasm"` to the members list:
|
|
|
|
```toml
|
|
[workspace]
|
|
resolver = "2"
|
|
members = [
|
|
"crates/relicario-core",
|
|
"crates/relicario-cli",
|
|
"crates/relicario-wasm",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 3: Write the WASM wrapper**
|
|
|
|
Create `crates/relicario-wasm/src/lib.rs`:
|
|
|
|
```rust
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
/// Derive a 32-byte master key from passphrase + image_secret + salt.
|
|
/// `params_json` is a JSON string like: {"argon2_m":65536,"argon2_t":3,"argon2_p":4}
|
|
#[wasm_bindgen]
|
|
pub fn derive_master_key(
|
|
passphrase: &str,
|
|
image_secret: &[u8],
|
|
salt: &[u8],
|
|
params_json: &str,
|
|
) -> Result<Vec<u8>, JsValue> {
|
|
let params: relicario_core::KdfParams =
|
|
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
|
|
let image_secret: [u8; 32] = image_secret
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
|
|
let salt: [u8; 32] = salt
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
|
|
|
|
let key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
|
|
Ok(key.to_vec())
|
|
}
|
|
|
|
/// Encrypt plaintext bytes with a 32-byte key. Returns version+nonce+ciphertext+tag.
|
|
#[wasm_bindgen]
|
|
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
let key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
relicario_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Decrypt ciphertext with a 32-byte key. Returns plaintext bytes.
|
|
#[wasm_bindgen]
|
|
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
let key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
relicario_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Extract a 256-bit secret from a JPEG with an embedded secret.
|
|
#[wasm_bindgen]
|
|
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
let secret =
|
|
relicario_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
Ok(secret.to_vec())
|
|
}
|
|
|
|
/// Encrypt an entry (JSON string) with a 32-byte key. Returns encrypted bytes.
|
|
#[wasm_bindgen]
|
|
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
let key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
let entry: relicario_core::Entry =
|
|
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
relicario_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Decrypt an entry from encrypted bytes. Returns JSON string.
|
|
#[wasm_bindgen]
|
|
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
|
|
let key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
let entry =
|
|
relicario_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Encrypt a manifest (JSON string) with a 32-byte key. Returns encrypted bytes.
|
|
#[wasm_bindgen]
|
|
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
|
let key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
let manifest: relicario_core::Manifest =
|
|
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
relicario_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Decrypt a manifest from encrypted bytes. Returns JSON string.
|
|
#[wasm_bindgen]
|
|
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
|
|
let key: [u8; 32] = key
|
|
.try_into()
|
|
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
|
let manifest = relicario_core::decrypt_manifest(&key, ciphertext)
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
|
|
}
|
|
|
|
/// Generate a TOTP code for the given base32-encoded secret and unix timestamp.
|
|
/// Returns a 6-digit zero-padded string.
|
|
#[wasm_bindgen]
|
|
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
|
|
use hmac::{Hmac, Mac};
|
|
use sha1::Sha1;
|
|
|
|
let secret = data_encoding::BASE32_NOPAD
|
|
.decode(secret_base32.to_uppercase().as_bytes())
|
|
.or_else(|_| data_encoding::BASE32.decode(secret_base32.to_uppercase().as_bytes()))
|
|
.map_err(|e| JsValue::from_str(&format!("invalid base32 secret: {e}")))?;
|
|
|
|
let counter = timestamp_secs / 30;
|
|
let counter_bytes = counter.to_be_bytes();
|
|
|
|
let mut mac = Hmac::<Sha1>::new_from_slice(&secret)
|
|
.map_err(|e| JsValue::from_str(&format!("HMAC error: {e}")))?;
|
|
mac.update(&counter_bytes);
|
|
let result = mac.finalize().into_bytes();
|
|
|
|
let offset = (result[19] & 0x0f) as usize;
|
|
let code = ((result[offset] as u32 & 0x7f) << 24)
|
|
| ((result[offset + 1] as u32) << 16)
|
|
| ((result[offset + 2] as u32) << 8)
|
|
| (result[offset + 3] as u32);
|
|
let code = code % 1_000_000;
|
|
|
|
Ok(format!("{:06}", code))
|
|
}
|
|
|
|
/// Generate a random password of the given length.
|
|
#[wasm_bindgen]
|
|
pub fn generate_password(length: u32) -> String {
|
|
use js_sys::Math;
|
|
|
|
const CHARSET: &[u8] =
|
|
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
|
|
|
(0..length)
|
|
.map(|_| {
|
|
let idx = (Math::random() * CHARSET.len() as f64).floor() as usize;
|
|
CHARSET[idx % CHARSET.len()] as char
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Generate a random 8-character hex entry ID.
|
|
#[wasm_bindgen]
|
|
pub fn generate_entry_id() -> String {
|
|
use js_sys::Math;
|
|
|
|
let mut bytes = [0u8; 4];
|
|
for b in &mut bytes {
|
|
*b = (Math::random() * 256.0).floor() as u8;
|
|
}
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn totp_rfc6238_test_vector() {
|
|
// RFC 6238 test vector: SHA1, secret = "12345678901234567890" (ASCII),
|
|
// time = 59, expected code = 287082
|
|
let secret_ascii = b"12345678901234567890";
|
|
let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
|
|
let result = generate_totp(&secret_b32, 59).unwrap();
|
|
assert_eq!(result, "287082");
|
|
}
|
|
|
|
#[test]
|
|
fn totp_rfc6238_test_vector_2() {
|
|
// time = 1111111109, expected = 081804
|
|
let secret_ascii = b"12345678901234567890";
|
|
let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
|
|
let result = generate_totp(&secret_b32, 1111111109).unwrap();
|
|
assert_eq!(result, "081804");
|
|
}
|
|
|
|
#[test]
|
|
fn totp_rfc6238_test_vector_3() {
|
|
// time = 1234567890, expected = 005924
|
|
let secret_ascii = b"12345678901234567890";
|
|
let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
|
|
let result = generate_totp(&secret_b32, 1234567890).unwrap();
|
|
assert_eq!(result, "005924");
|
|
}
|
|
|
|
#[test]
|
|
fn totp_invalid_base32_fails() {
|
|
let result = generate_totp("not-valid-base32!!!", 1000);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn derive_key_via_wasm_wrapper() {
|
|
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
|
let image_secret = [0x42u8; 32];
|
|
let salt = [0x01u8; 32];
|
|
let key = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap();
|
|
assert_eq!(key.len(), 32);
|
|
|
|
// Same inputs should produce same output
|
|
let key2 = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap();
|
|
assert_eq!(key, key2);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_decrypt_via_wasm_wrapper() {
|
|
let key = [0xABu8; 32];
|
|
let plaintext = b"hello wasm";
|
|
let ciphertext = encrypt(plaintext, &key).unwrap();
|
|
let decrypted = decrypt(&ciphertext, &key).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_entry_decrypt_entry_round_trip() {
|
|
let key = [0xABu8; 32];
|
|
let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
|
|
let ciphertext = encrypt_entry(entry_json, &key).unwrap();
|
|
let result = decrypt_entry(&ciphertext, &key).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["name"], "Test");
|
|
assert_eq!(parsed["password"], "secret");
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify it compiles**
|
|
|
|
Run: `cargo build -p relicario-wasm`
|
|
Expected: Compiles successfully.
|
|
|
|
- [ ] **Step 5: Run tests**
|
|
|
|
Run: `cargo test -p relicario-wasm`
|
|
Expected: All tests pass, including TOTP RFC 6238 test vectors.
|
|
|
|
- [ ] **Step 6: Test WASM compilation**
|
|
|
|
Run: `cargo install wasm-pack` (if not already installed), then:
|
|
```bash
|
|
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
|
|
```
|
|
Expected: Produces `extension/wasm/relicario_wasm.js` and `extension/wasm/relicario_wasm_bg.wasm`. Note the WASM binary size for later reference.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add crates/relicario-wasm/ Cargo.toml extension/wasm/
|
|
git commit -m "feat: add relicario-wasm crate with wasm-bindgen wrappers and TOTP"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Extension Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `extension/manifest.json`
|
|
- Create: `extension/package.json`
|
|
- Create: `extension/tsconfig.json`
|
|
- Create: `extension/webpack.config.js`
|
|
- Create: `extension/src/popup/index.html`
|
|
- Create: `extension/icons/` (placeholder icons)
|
|
- Modify: `.gitignore` (add extension build artifacts)
|
|
|
|
- [ ] **Step 1: Create `extension/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "relicario-extension",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"build": "webpack --mode production",
|
|
"dev": "webpack --mode development --watch",
|
|
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
|
|
"build:all": "npm run build:wasm && npm run build"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "^5.4",
|
|
"ts-loader": "^9.5",
|
|
"webpack": "^5.90",
|
|
"webpack-cli": "^5.1",
|
|
"copy-webpack-plugin": "^12.0",
|
|
"@anthropic-ai/sdk": "^0.39"
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: `@anthropic-ai/sdk` is NOT needed — remove that. The devDependencies should be:
|
|
|
|
```json
|
|
{
|
|
"name": "relicario-extension",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"build": "webpack --mode production",
|
|
"dev": "webpack --mode development --watch",
|
|
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
|
|
"build:all": "npm run build:wasm && npm run build"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "^5.4",
|
|
"ts-loader": "^9.5",
|
|
"webpack": "^5.90",
|
|
"webpack-cli": "^5.1",
|
|
"copy-webpack-plugin": "^12.0",
|
|
"@anthropic-ai/sdk": "^0.39.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
Actually, strike that — no anthropic SDK. Final version:
|
|
|
|
```json
|
|
{
|
|
"name": "relicario-extension",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"build": "webpack --mode production",
|
|
"dev": "webpack --mode development --watch",
|
|
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
|
|
"build:all": "npm run build:wasm && npm run build"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "^5.4",
|
|
"ts-loader": "^9.5",
|
|
"webpack": "^5.90",
|
|
"webpack-cli": "^5.1",
|
|
"copy-webpack-plugin": "^12.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `extension/tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"module": "ES2022",
|
|
"moduleResolution": "bundler",
|
|
"strict": true,
|
|
"esModuleInterop": true,
|
|
"outDir": "./dist",
|
|
"rootDir": "./src",
|
|
"sourceMap": true,
|
|
"lib": ["ES2022", "DOM"]
|
|
},
|
|
"include": ["src/**/*"],
|
|
"exclude": ["node_modules", "dist", "wasm"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `extension/webpack.config.js`**
|
|
|
|
```javascript
|
|
const path = require('path');
|
|
const CopyPlugin = require('copy-webpack-plugin');
|
|
|
|
module.exports = {
|
|
entry: {
|
|
'service-worker': './src/service-worker/index.ts',
|
|
popup: './src/popup/popup.ts',
|
|
content: './src/content/detector.ts',
|
|
},
|
|
output: {
|
|
path: path.resolve(__dirname, 'dist'),
|
|
filename: '[name].js',
|
|
clean: true,
|
|
},
|
|
resolve: {
|
|
extensions: ['.ts', '.js'],
|
|
},
|
|
module: {
|
|
rules: [
|
|
{
|
|
test: /\.ts$/,
|
|
use: 'ts-loader',
|
|
exclude: /node_modules/,
|
|
},
|
|
],
|
|
},
|
|
plugins: [
|
|
new CopyPlugin({
|
|
patterns: [
|
|
{ from: 'manifest.json', to: '.' },
|
|
{ from: 'src/popup/index.html', to: 'popup.html' },
|
|
{ from: 'src/popup/styles.css', to: 'styles.css' },
|
|
{ from: 'icons', to: 'icons' },
|
|
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
|
|
{ from: 'wasm/relicario_wasm.js', to: '.' },
|
|
],
|
|
}),
|
|
],
|
|
experiments: {
|
|
asyncWebAssembly: true,
|
|
},
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Create `extension/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'"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create popup HTML shell**
|
|
|
|
Create `extension/src/popup/index.html`:
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=360">
|
|
<link rel="stylesheet" href="styles.css">
|
|
<title>relicario</title>
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<script src="popup.js"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 6: Create placeholder icons**
|
|
|
|
Generate simple placeholder PNGs for `extension/icons/`. These are 1x1 blue pixels scaled to 16, 48, 128 — functional placeholders to avoid Chrome errors. Use a simple script or create them manually.
|
|
|
|
```bash
|
|
cd extension && mkdir -p icons
|
|
# Create minimal valid PNGs (can replace with real icons later)
|
|
python3 -c "
|
|
import struct, zlib
|
|
def make_png(size, path):
|
|
raw = b''
|
|
for y in range(size):
|
|
raw += b'\x00' + b'\x1f\x6f\xeb\xff' * size
|
|
def chunk(ctype, data):
|
|
c = ctype + data
|
|
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
|
|
ihdr = struct.pack('>IIBBBBB', size, size, 8, 6, 0, 0, 0)
|
|
with open(path, 'wb') as f:
|
|
f.write(b'\x89PNG\r\n\x1a\n')
|
|
f.write(chunk(b'IHDR', ihdr))
|
|
f.write(chunk(b'IDAT', zlib.compress(raw)))
|
|
f.write(chunk(b'IEND', b''))
|
|
make_png(16, 'icons/icon-16.png')
|
|
make_png(48, 'icons/icon-48.png')
|
|
make_png(128, 'icons/icon-128.png')
|
|
"
|
|
```
|
|
|
|
- [ ] **Step 7: Add to `.gitignore`**
|
|
|
|
Append to the root `.gitignore`:
|
|
|
|
```
|
|
extension/node_modules/
|
|
extension/dist/
|
|
.superpowers/
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add extension/manifest.json extension/package.json extension/tsconfig.json extension/webpack.config.js extension/src/popup/index.html extension/icons/ .gitignore
|
|
git commit -m "feat: add extension scaffolding (manifest, webpack, tsconfig)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Shared Types and Message Definitions
|
|
|
|
**Files:**
|
|
- Create: `extension/src/shared/types.ts`
|
|
- Create: `extension/src/shared/messages.ts`
|
|
|
|
- [ ] **Step 1: Create shared types**
|
|
|
|
Create `extension/src/shared/types.ts`:
|
|
|
|
```typescript
|
|
export interface Entry {
|
|
name: string;
|
|
url?: string;
|
|
username?: string;
|
|
password: string;
|
|
notes?: string;
|
|
totp_secret?: string;
|
|
group?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ManifestEntry {
|
|
name: string;
|
|
url?: string;
|
|
username?: string;
|
|
group?: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface Manifest {
|
|
entries: Record<string, ManifestEntry>;
|
|
version: number;
|
|
}
|
|
|
|
export interface VaultConfig {
|
|
hostType: 'gitea' | 'github';
|
|
hostUrl: string;
|
|
repoPath: string;
|
|
apiToken: string;
|
|
}
|
|
|
|
export interface SetupState {
|
|
config: VaultConfig | null;
|
|
imageBase64: string | null; // reference JPEG stored as base64
|
|
isConfigured: boolean;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create message definitions**
|
|
|
|
Create `extension/src/shared/messages.ts`:
|
|
|
|
```typescript
|
|
import type { Entry, ManifestEntry, VaultConfig } from './types';
|
|
|
|
// ─── Requests (popup/content → service worker) ─────────────────────────────
|
|
|
|
export type Request =
|
|
| { type: 'unlock'; passphrase: string }
|
|
| { type: 'lock' }
|
|
| { type: 'is_unlocked' }
|
|
| { type: 'list_entries'; group?: string }
|
|
| { type: 'get_entry'; id: string }
|
|
| { type: 'search_entries'; query: string }
|
|
| { type: 'add_entry'; entry: Omit<Entry, 'created_at' | 'updated_at'> }
|
|
| { type: 'update_entry'; id: string; entry: Omit<Entry, 'created_at' | 'updated_at'> }
|
|
| { type: 'delete_entry'; id: string }
|
|
| { type: 'get_totp'; id: string }
|
|
| { type: 'get_autofill_candidates'; url: string }
|
|
| { type: 'get_credentials'; id: string }
|
|
| { type: 'sync' }
|
|
| { type: 'get_setup_state' }
|
|
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
|
| { type: 'fill_credentials'; username: string; password: string };
|
|
|
|
// ─── Responses (service worker → popup/content) ────────────────────────────
|
|
|
|
export type Response =
|
|
| { ok: true }
|
|
| { ok: true; id: string }
|
|
| { ok: true; unlocked: boolean }
|
|
| { ok: true; entries: Array<{ id: string } & ManifestEntry> }
|
|
| { ok: true; entry: Entry }
|
|
| { ok: true; code: string; remaining_seconds: number }
|
|
| { ok: true; candidates: Array<{ id: string } & ManifestEntry> }
|
|
| { ok: true; username: string; password: string }
|
|
| { ok: true; state: import('./types').SetupState }
|
|
| { error: string };
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add extension/src/shared/
|
|
git commit -m "feat: add shared types and message definitions"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Git API Layer
|
|
|
|
**Files:**
|
|
- Create: `extension/src/service-worker/git-host.ts`
|
|
- Create: `extension/src/service-worker/gitea.ts`
|
|
- Create: `extension/src/service-worker/github.ts`
|
|
|
|
- [ ] **Step 1: Create the GitHost interface**
|
|
|
|
Create `extension/src/service-worker/git-host.ts`:
|
|
|
|
```typescript
|
|
export 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[]>;
|
|
}
|
|
|
|
export function createGitHost(
|
|
hostType: 'gitea' | 'github',
|
|
hostUrl: string,
|
|
repoPath: string,
|
|
apiToken: string
|
|
): GitHost {
|
|
switch (hostType) {
|
|
case 'gitea':
|
|
return new (require('./gitea').GiteaHost)(hostUrl, repoPath, apiToken);
|
|
case 'github':
|
|
return new (require('./github').GitHubHost)(hostUrl, repoPath, apiToken);
|
|
}
|
|
}
|
|
```
|
|
|
|
Actually, since we're using ES modules and webpack, use dynamic imports or just import both. Simpler approach:
|
|
|
|
```typescript
|
|
import { GiteaHost } from './gitea';
|
|
import { GitHubHost } from './github';
|
|
|
|
export 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[]>;
|
|
}
|
|
|
|
export function createGitHost(
|
|
hostType: 'gitea' | 'github',
|
|
hostUrl: string,
|
|
repoPath: string,
|
|
apiToken: string
|
|
): GitHost {
|
|
if (hostType === 'gitea') {
|
|
return new GiteaHost(hostUrl, repoPath, apiToken);
|
|
}
|
|
return new GitHubHost(hostUrl, repoPath, apiToken);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create Gitea implementation**
|
|
|
|
Create `extension/src/service-worker/gitea.ts`:
|
|
|
|
```typescript
|
|
import type { GitHost } from './git-host';
|
|
|
|
export class GiteaHost implements GitHost {
|
|
private baseUrl: string;
|
|
private headers: HeadersInit;
|
|
|
|
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
|
// Remove trailing slash from hostUrl
|
|
const host = hostUrl.replace(/\/+$/, '');
|
|
this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
|
|
this.headers = {
|
|
Authorization: `token ${apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
async readFile(path: string): Promise<Uint8Array> {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
const data = await resp.json();
|
|
const decoded = atob(data.content);
|
|
const bytes = new Uint8Array(decoded.length);
|
|
for (let i = 0; i < decoded.length; i++) {
|
|
bytes[i] = decoded.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
|
|
// Check if file exists to get its SHA
|
|
let sha: string | undefined;
|
|
try {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
sha = data.sha;
|
|
}
|
|
} catch {
|
|
// File doesn't exist, that's fine
|
|
}
|
|
|
|
const body: Record<string, string> = {
|
|
message,
|
|
content: uint8ArrayToBase64(content),
|
|
};
|
|
if (sha) {
|
|
body.sha = sha;
|
|
}
|
|
|
|
const method = sha ? 'PUT' : 'POST';
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
method,
|
|
headers: this.headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
}
|
|
|
|
async deleteFile(path: string, message: string): Promise<void> {
|
|
// Get file SHA first
|
|
const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (!getResp.ok) {
|
|
throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`);
|
|
}
|
|
const data = await getResp.json();
|
|
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
method: 'DELETE',
|
|
headers: this.headers,
|
|
body: JSON.stringify({ message, sha: data.sha }),
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
}
|
|
|
|
async listDir(path: string): Promise<string[]> {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (!resp.ok) {
|
|
if (resp.status === 404) return [];
|
|
throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
const data = await resp.json();
|
|
if (!Array.isArray(data)) return [];
|
|
return data.map((item: { name: string }) => item.name);
|
|
}
|
|
}
|
|
|
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create GitHub implementation**
|
|
|
|
Create `extension/src/service-worker/github.ts`:
|
|
|
|
```typescript
|
|
import type { GitHost } from './git-host';
|
|
|
|
export class GitHubHost implements GitHost {
|
|
private baseUrl: string;
|
|
private headers: HeadersInit;
|
|
|
|
constructor(_hostUrl: string, repoPath: string, apiToken: string) {
|
|
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
|
this.headers = {
|
|
Authorization: `Bearer ${apiToken}`,
|
|
Accept: 'application/vnd.github.v3+json',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
async readFile(path: string): Promise<Uint8Array> {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
const data = await resp.json();
|
|
const decoded = atob(data.content.replace(/\n/g, ''));
|
|
const bytes = new Uint8Array(decoded.length);
|
|
for (let i = 0; i < decoded.length; i++) {
|
|
bytes[i] = decoded.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
|
|
let sha: string | undefined;
|
|
try {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
sha = data.sha;
|
|
}
|
|
} catch {
|
|
// File doesn't exist
|
|
}
|
|
|
|
const body: Record<string, string> = {
|
|
message,
|
|
content: uint8ArrayToBase64(content),
|
|
};
|
|
if (sha) {
|
|
body.sha = sha;
|
|
}
|
|
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
method: 'PUT',
|
|
headers: this.headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
}
|
|
|
|
async deleteFile(path: string, message: string): Promise<void> {
|
|
const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (!getResp.ok) {
|
|
throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`);
|
|
}
|
|
const data = await getResp.json();
|
|
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
|
method: 'DELETE',
|
|
headers: this.headers,
|
|
body: JSON.stringify({ message, sha: data.sha }),
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
}
|
|
|
|
async listDir(path: string): Promise<string[]> {
|
|
const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
|
if (!resp.ok) {
|
|
if (resp.status === 404) return [];
|
|
throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
const data = await resp.json();
|
|
if (!Array.isArray(data)) return [];
|
|
return data.map((item: { name: string }) => item.name);
|
|
}
|
|
}
|
|
|
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts
|
|
git commit -m "feat: add git API layer with Gitea and GitHub implementations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Service Worker — Vault Operations
|
|
|
|
**Files:**
|
|
- Create: `extension/src/service-worker/vault.ts`
|
|
|
|
- [ ] **Step 1: Create vault operations module**
|
|
|
|
Create `extension/src/service-worker/vault.ts`:
|
|
|
|
```typescript
|
|
import type { GitHost } from './git-host';
|
|
import type { Entry, Manifest, ManifestEntry } from '../shared/types';
|
|
|
|
// These will be set by the service worker index after WASM init
|
|
let wasm: typeof import('../../wasm/relicario_wasm');
|
|
|
|
export function setWasm(w: typeof wasm) {
|
|
wasm = w;
|
|
}
|
|
|
|
export async function fetchVaultMeta(git: GitHost): Promise<{ salt: Uint8Array; paramsJson: string }> {
|
|
const salt = await git.readFile('.relicario/salt');
|
|
const paramsBytes = await git.readFile('.relicario/params.json');
|
|
const paramsJson = new TextDecoder().decode(paramsBytes);
|
|
return { salt, paramsJson };
|
|
}
|
|
|
|
export async function fetchAndDecryptManifest(git: GitHost, masterKey: Uint8Array): Promise<Manifest> {
|
|
const encBytes = await git.readFile('manifest.enc');
|
|
const json = wasm.decrypt_manifest(encBytes, masterKey);
|
|
return JSON.parse(json);
|
|
}
|
|
|
|
export async function fetchAndDecryptEntry(git: GitHost, masterKey: Uint8Array, id: string): Promise<Entry> {
|
|
const encBytes = await git.readFile(`entries/${id}.enc`);
|
|
const json = wasm.decrypt_entry(encBytes, masterKey);
|
|
return JSON.parse(json);
|
|
}
|
|
|
|
export async function encryptAndWriteEntry(
|
|
git: GitHost,
|
|
masterKey: Uint8Array,
|
|
id: string,
|
|
entry: Entry,
|
|
commitMsg: string
|
|
): Promise<void> {
|
|
const json = JSON.stringify(entry);
|
|
const encBytes = wasm.encrypt_entry(json, masterKey);
|
|
await git.writeFile(`entries/${id}.enc`, encBytes, commitMsg);
|
|
}
|
|
|
|
export async function encryptAndWriteManifest(
|
|
git: GitHost,
|
|
masterKey: Uint8Array,
|
|
manifest: Manifest,
|
|
commitMsg: string
|
|
): Promise<void> {
|
|
const json = JSON.stringify(manifest);
|
|
const encBytes = wasm.encrypt_manifest(json, masterKey);
|
|
await git.writeFile('manifest.enc', encBytes, commitMsg);
|
|
}
|
|
|
|
export function listEntries(manifest: Manifest, group?: string): Array<{ id: string } & ManifestEntry> {
|
|
const entries = Object.entries(manifest.entries).map(([id, entry]) => ({ id, ...entry }));
|
|
if (group) {
|
|
return entries.filter((e) => e.group === group);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
export function searchEntries(manifest: Manifest, query: string): Array<{ id: string } & ManifestEntry> {
|
|
const q = query.toLowerCase();
|
|
return Object.entries(manifest.entries)
|
|
.filter(([, e]) => {
|
|
return (
|
|
e.name.toLowerCase().includes(q) ||
|
|
(e.url && e.url.toLowerCase().includes(q)) ||
|
|
(e.username && e.username.toLowerCase().includes(q))
|
|
);
|
|
})
|
|
.map(([id, entry]) => ({ id, ...entry }));
|
|
}
|
|
|
|
export function findByUrl(manifest: Manifest, url: string): Array<{ id: string } & ManifestEntry> {
|
|
let hostname: string;
|
|
try {
|
|
hostname = new URL(url).hostname;
|
|
} catch {
|
|
return [];
|
|
}
|
|
return Object.entries(manifest.entries)
|
|
.filter(([, e]) => {
|
|
if (!e.url) return false;
|
|
try {
|
|
return new URL(e.url).hostname === hostname;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
.map(([id, entry]) => ({ id, ...entry }));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add extension/src/service-worker/vault.ts
|
|
git commit -m "feat: add vault operations module for service worker"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Service Worker — Main Entry Point
|
|
|
|
**Files:**
|
|
- Create: `extension/src/service-worker/index.ts`
|
|
|
|
- [ ] **Step 1: Create the service worker entry point**
|
|
|
|
Create `extension/src/service-worker/index.ts`:
|
|
|
|
```typescript
|
|
import type { Manifest, VaultConfig, SetupState } from '../shared/types';
|
|
import type { Request, Response } from '../shared/messages';
|
|
import { createGitHost, type GitHost } from './git-host';
|
|
import {
|
|
setWasm,
|
|
fetchVaultMeta,
|
|
fetchAndDecryptManifest,
|
|
fetchAndDecryptEntry,
|
|
encryptAndWriteEntry,
|
|
encryptAndWriteManifest,
|
|
listEntries,
|
|
searchEntries,
|
|
findByUrl,
|
|
} from './vault';
|
|
|
|
// ─── State ──────────────────────────────────────────────────────────────────
|
|
|
|
let masterKey: Uint8Array | null = null;
|
|
let manifest: Manifest | null = null;
|
|
let gitHost: GitHost | null = null;
|
|
let wasm: typeof import('../../wasm/relicario_wasm') | null = null;
|
|
|
|
// ─── WASM initialization ───────────────────────────────────────────────────
|
|
|
|
async function initWasm(): Promise<typeof import('../../wasm/relicario_wasm')> {
|
|
if (wasm) return wasm;
|
|
const mod = await import(/* webpackIgnore: true */ './relicario_wasm.js');
|
|
await mod.default();
|
|
wasm = mod;
|
|
setWasm(mod);
|
|
return mod;
|
|
}
|
|
|
|
// ─── Config helpers ─────────────────────────────────────────────────────────
|
|
|
|
async function loadConfig(): Promise<{ config: VaultConfig; imageBase64: string } | null> {
|
|
const data = await chrome.storage.local.get(['vaultConfig', 'imageBase64']);
|
|
if (!data.vaultConfig || !data.imageBase64) return null;
|
|
return { config: data.vaultConfig, imageBase64: data.imageBase64 };
|
|
}
|
|
|
|
async function saveConfig(config: VaultConfig, imageBase64: string): Promise<void> {
|
|
await chrome.storage.local.set({ vaultConfig: config, imageBase64 });
|
|
}
|
|
|
|
function base64ToUint8Array(base64: string): Uint8Array {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function isoNow(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
// ─── Message handler ────────────────────────────────────────────────────────
|
|
|
|
chrome.runtime.onMessage.addListener(
|
|
(request: Request, _sender, sendResponse: (response: Response) => void) => {
|
|
handleMessage(request)
|
|
.then(sendResponse)
|
|
.catch((err) => sendResponse({ error: String(err) }));
|
|
return true; // async response
|
|
}
|
|
);
|
|
|
|
async function handleMessage(req: Request): Promise<Response> {
|
|
const w = await initWasm();
|
|
|
|
switch (req.type) {
|
|
case 'get_setup_state': {
|
|
const stored = await loadConfig();
|
|
const state: SetupState = {
|
|
config: stored?.config ?? null,
|
|
imageBase64: stored?.imageBase64 ?? null,
|
|
isConfigured: stored !== null,
|
|
};
|
|
return { ok: true, state } as Response;
|
|
}
|
|
|
|
case 'save_setup': {
|
|
await saveConfig(req.config, req.imageBase64);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'is_unlocked': {
|
|
return { ok: true, unlocked: masterKey !== null } as Response;
|
|
}
|
|
|
|
case 'unlock': {
|
|
const stored = await loadConfig();
|
|
if (!stored) return { error: 'Extension not configured. Run setup first.' };
|
|
|
|
const imageBytes = base64ToUint8Array(stored.imageBase64);
|
|
const imageSecret = w.extract_image_secret(imageBytes);
|
|
|
|
gitHost = createGitHost(
|
|
stored.config.hostType,
|
|
stored.config.hostUrl,
|
|
stored.config.repoPath,
|
|
stored.config.apiToken
|
|
);
|
|
|
|
const { salt, paramsJson } = await fetchVaultMeta(gitHost);
|
|
const key = w.derive_master_key(req.passphrase, imageSecret, salt, paramsJson);
|
|
masterKey = new Uint8Array(key);
|
|
|
|
manifest = await fetchAndDecryptManifest(gitHost, masterKey);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'lock': {
|
|
masterKey = null;
|
|
manifest = null;
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'list_entries': {
|
|
if (!manifest) return { error: 'Vault is locked' };
|
|
const entries = listEntries(manifest, req.group);
|
|
return { ok: true, entries } as Response;
|
|
}
|
|
|
|
case 'search_entries': {
|
|
if (!manifest) return { error: 'Vault is locked' };
|
|
const entries = searchEntries(manifest, req.query);
|
|
return { ok: true, entries } as Response;
|
|
}
|
|
|
|
case 'get_entry': {
|
|
if (!masterKey || !gitHost) return { error: 'Vault is locked' };
|
|
const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
return { ok: true, entry } as Response;
|
|
}
|
|
|
|
case 'add_entry': {
|
|
if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
|
|
const now = isoNow();
|
|
const entry = { ...req.entry, created_at: now, updated_at: now };
|
|
const id = w.generate_entry_id();
|
|
|
|
await encryptAndWriteEntry(gitHost, masterKey, id, entry, `add entry '${entry.name}'`);
|
|
|
|
manifest.entries[id] = {
|
|
name: entry.name,
|
|
url: entry.url,
|
|
username: entry.username,
|
|
group: entry.group,
|
|
updated_at: now,
|
|
};
|
|
await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: add '${entry.name}'`);
|
|
|
|
return { ok: true, id } as Response;
|
|
}
|
|
|
|
case 'update_entry': {
|
|
if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
|
|
const existing = manifest.entries[req.id];
|
|
if (!existing) return { error: `Entry ${req.id} not found` };
|
|
|
|
const now = isoNow();
|
|
const oldEntry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
const updated = { ...req.entry, created_at: oldEntry.created_at, updated_at: now };
|
|
|
|
await encryptAndWriteEntry(gitHost, masterKey, req.id, updated, `edit entry '${updated.name}'`);
|
|
|
|
manifest.entries[req.id] = {
|
|
name: updated.name,
|
|
url: updated.url,
|
|
username: updated.username,
|
|
group: updated.group,
|
|
updated_at: now,
|
|
};
|
|
await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: edit '${updated.name}'`);
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'delete_entry': {
|
|
if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
|
|
const entry = manifest.entries[req.id];
|
|
if (!entry) return { error: `Entry ${req.id} not found` };
|
|
|
|
await gitHost.deleteFile(`entries/${req.id}.enc`, `remove entry '${entry.name}'`);
|
|
delete manifest.entries[req.id];
|
|
await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: remove '${entry.name}'`);
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'get_totp': {
|
|
if (!masterKey || !gitHost) return { error: 'Vault is locked' };
|
|
const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
if (!entry.totp_secret) return { error: 'No TOTP secret for this entry' };
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const code = w.generate_totp(entry.totp_secret, BigInt(now));
|
|
const remaining = 30 - (now % 30);
|
|
return { ok: true, code, remaining_seconds: remaining } as Response;
|
|
}
|
|
|
|
case 'get_autofill_candidates': {
|
|
if (!manifest) return { error: 'Vault is locked' };
|
|
const candidates = findByUrl(manifest, req.url);
|
|
return { ok: true, candidates } as Response;
|
|
}
|
|
|
|
case 'get_credentials': {
|
|
if (!masterKey || !gitHost) return { error: 'Vault is locked' };
|
|
const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
return { ok: true, username: entry.username ?? '', password: entry.password } as Response;
|
|
}
|
|
|
|
case 'sync': {
|
|
if (!masterKey || !gitHost) return { error: 'Vault is locked' };
|
|
manifest = await fetchAndDecryptManifest(gitHost, masterKey);
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'fill_credentials': {
|
|
// This is handled by the content script directly, not the service worker
|
|
return { ok: true };
|
|
}
|
|
|
|
default:
|
|
return { error: `Unknown message type: ${(req as Request).type}` };
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add extension/src/service-worker/index.ts
|
|
git commit -m "feat: add service worker with WASM init, state management, and message router"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Popup — Styles
|
|
|
|
**Files:**
|
|
- Create: `extension/src/popup/styles.css`
|
|
|
|
- [ ] **Step 1: Create the terminal dark theme**
|
|
|
|
Create `extension/src/popup/styles.css`:
|
|
|
|
```css
|
|
/* ─── Reset & Base ───────────────────────────────────────────────────────── */
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
width: 360px;
|
|
min-height: 200px;
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
background: #0d1117;
|
|
color: #c9d1d9;
|
|
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Menlo', monospace;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
#app {
|
|
padding: 16px;
|
|
}
|
|
|
|
/* ─── Typography ─────────────────────────────────────────────────────────── */
|
|
|
|
.brand {
|
|
color: #58a6ff;
|
|
font-size: 14px;
|
|
letter-spacing: 2px;
|
|
font-weight: normal;
|
|
}
|
|
|
|
.label {
|
|
color: #484f58;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.secondary {
|
|
color: #8b949e;
|
|
}
|
|
|
|
.muted {
|
|
color: #484f58;
|
|
}
|
|
|
|
.error {
|
|
color: #f85149;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ─── Inputs ─────────────────────────────────────────────────────────────── */
|
|
|
|
input, textarea {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
color: #c9d1d9;
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
padding: 6px 10px;
|
|
border-radius: 2px;
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
input:focus, textarea:focus {
|
|
border-color: #58a6ff;
|
|
}
|
|
|
|
input::placeholder, textarea::placeholder {
|
|
color: #484f58;
|
|
}
|
|
|
|
/* ─── Buttons ────────────────────────────────────────────────────────────── */
|
|
|
|
.btn {
|
|
background: #21262d;
|
|
border: 1px solid #30363d;
|
|
color: #c9d1d9;
|
|
font-family: inherit;
|
|
font-size: 11px;
|
|
padding: 4px 12px;
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn:hover {
|
|
background: #30363d;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #1f6feb;
|
|
border-color: #1f6feb;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #388bfd;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #da3633;
|
|
border-color: #da3633;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #f85149;
|
|
}
|
|
|
|
/* ─── Header ─────────────────────────────────────────────────────────────── */
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.status {
|
|
color: #484f58;
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* ─── Search ─────────────────────────────────────────────────────────────── */
|
|
|
|
.search-bar {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* ─── Group Tabs ─────────────────────────────────────────────────────────── */
|
|
|
|
.group-tabs {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin-bottom: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.group-tab {
|
|
background: #21262d;
|
|
border: 1px solid #30363d;
|
|
padding: 2px 8px;
|
|
border-radius: 2px;
|
|
font-size: 10px;
|
|
color: #8b949e;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.group-tab:hover {
|
|
background: #30363d;
|
|
}
|
|
|
|
.group-tab.active {
|
|
background: #1f6feb;
|
|
border-color: #1f6feb;
|
|
color: #fff;
|
|
}
|
|
|
|
/* ─── Entry List ─────────────────────────────────────────────────────────── */
|
|
|
|
.entry-list {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.entry-row {
|
|
padding: 6px 10px;
|
|
border-left: 2px solid transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.entry-row:hover {
|
|
background: #161b22;
|
|
}
|
|
|
|
.entry-row.selected {
|
|
border-left-color: #58a6ff;
|
|
background: #161b22;
|
|
}
|
|
|
|
.entry-name {
|
|
color: #c9d1d9;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.entry-meta {
|
|
color: #484f58;
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* ─── Entry Detail ───────────────────────────────────────────────────────── */
|
|
|
|
.detail-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.detail-title {
|
|
color: #58a6ff;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.field {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.field-value {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.field-value .hint {
|
|
color: #484f58;
|
|
font-size: 10px;
|
|
}
|
|
|
|
/* ─── TOTP ───────────────────────────────────────────────────────────────── */
|
|
|
|
.totp-code {
|
|
color: #3fb950;
|
|
font-size: 16px;
|
|
letter-spacing: 4px;
|
|
}
|
|
|
|
.totp-bar {
|
|
height: 2px;
|
|
background: #21262d;
|
|
margin-top: 4px;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.totp-bar-fill {
|
|
height: 2px;
|
|
background: #3fb950;
|
|
border-radius: 1px;
|
|
transition: width 1s linear;
|
|
}
|
|
|
|
/* ─── Footer / Keybinding Hints ──────────────────────────────────────────── */
|
|
|
|
.keyhints {
|
|
color: #484f58;
|
|
font-size: 10px;
|
|
margin-top: 8px;
|
|
padding-top: 8px;
|
|
border-top: 1px solid #21262d;
|
|
}
|
|
|
|
/* ─── Setup Wizard ───────────────────────────────────────────────────────── */
|
|
|
|
.wizard-step {
|
|
color: #484f58;
|
|
font-size: 10px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 2px;
|
|
background: #21262d;
|
|
margin-bottom: 16px;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.progress-bar-fill {
|
|
height: 2px;
|
|
background: #1f6feb;
|
|
border-radius: 1px;
|
|
transition: width 0.3s;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.host-toggle {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
/* ─── Spinner ────────────────────────────────────────────────────────────── */
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px solid #30363d;
|
|
border-top-color: #58a6ff;
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* ─── Actions Bar ────────────────────────────────────────────────────────── */
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
/* ─── Confirm Dialog ─────────────────────────────────────────────────────── */
|
|
|
|
.confirm-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
|
|
.confirm-box {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
padding: 16px;
|
|
border-radius: 4px;
|
|
max-width: 300px;
|
|
}
|
|
|
|
/* ─── Empty State ────────────────────────────────────────────────────────── */
|
|
|
|
.empty {
|
|
color: #484f58;
|
|
text-align: center;
|
|
padding: 24px 0;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ─── Scrollbar ──────────────────────────────────────────────────────────── */
|
|
|
|
::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #30363d;
|
|
border-radius: 2px;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add extension/src/popup/styles.css
|
|
git commit -m "feat: add terminal dark theme for popup"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Popup — State Machine and Components
|
|
|
|
**Files:**
|
|
- Create: `extension/src/popup/popup.ts`
|
|
- Create: `extension/src/popup/components/unlock.ts`
|
|
- Create: `extension/src/popup/components/entry-list.ts`
|
|
- Create: `extension/src/popup/components/entry-detail.ts`
|
|
- Create: `extension/src/popup/components/entry-form.ts`
|
|
- Create: `extension/src/popup/components/setup-wizard.ts`
|
|
|
|
This is the largest task. Each component is a function that renders into a container element and attaches its own event listeners. The popup state machine orchestrates transitions.
|
|
|
|
- [ ] **Step 1: Create the popup state machine**
|
|
|
|
Create `extension/src/popup/popup.ts`:
|
|
|
|
```typescript
|
|
import type { Request, Response } from '../shared/messages';
|
|
import { renderUnlock } from './components/unlock';
|
|
import { renderEntryList } from './components/entry-list';
|
|
import { renderEntryDetail } from './components/entry-detail';
|
|
import { renderEntryForm } from './components/entry-form';
|
|
import { renderSetupWizard } from './components/setup-wizard';
|
|
|
|
export type PopupState =
|
|
| { view: 'setup' }
|
|
| { view: 'locked' }
|
|
| { view: 'list' }
|
|
| { view: 'detail'; entryId: string }
|
|
| { view: 'add' }
|
|
| { view: 'edit'; entryId: string };
|
|
|
|
let currentState: PopupState = { view: 'locked' };
|
|
|
|
export function sendMessage(request: Request): Promise<Response> {
|
|
return chrome.runtime.sendMessage(request);
|
|
}
|
|
|
|
export function navigate(state: PopupState) {
|
|
currentState = state;
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
const app = document.getElementById('app')!;
|
|
app.innerHTML = '';
|
|
|
|
switch (currentState.view) {
|
|
case 'setup':
|
|
renderSetupWizard(app);
|
|
break;
|
|
case 'locked':
|
|
renderUnlock(app);
|
|
break;
|
|
case 'list':
|
|
renderEntryList(app);
|
|
break;
|
|
case 'detail':
|
|
renderEntryDetail(app, currentState.entryId);
|
|
break;
|
|
case 'add':
|
|
renderEntryForm(app, null);
|
|
break;
|
|
case 'edit':
|
|
renderEntryForm(app, currentState.entryId);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ─── Init ───────────────────────────────────────────────────────────────────
|
|
|
|
async function init() {
|
|
const resp = await sendMessage({ type: 'get_setup_state' });
|
|
if ('error' in resp) {
|
|
navigate({ view: 'setup' });
|
|
return;
|
|
}
|
|
|
|
const state = (resp as { ok: true; state: import('../shared/types').SetupState }).state;
|
|
if (!state.isConfigured) {
|
|
navigate({ view: 'setup' });
|
|
return;
|
|
}
|
|
|
|
const unlockResp = await sendMessage({ type: 'is_unlocked' });
|
|
if ('error' in unlockResp) {
|
|
navigate({ view: 'locked' });
|
|
return;
|
|
}
|
|
|
|
const unlocked = (unlockResp as { ok: true; unlocked: boolean }).unlocked;
|
|
navigate(unlocked ? { view: 'list' } : { view: 'locked' });
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
```
|
|
|
|
- [ ] **Step 2: Create unlock component**
|
|
|
|
Create `extension/src/popup/components/unlock.ts`:
|
|
|
|
```typescript
|
|
import { sendMessage, navigate } from '../popup';
|
|
|
|
export function renderUnlock(container: HTMLElement) {
|
|
container.innerHTML = `
|
|
<div class="brand">relicario</div>
|
|
<div style="margin-top: 16px">
|
|
<div class="label">PASSPHRASE</div>
|
|
<input type="password" id="passphrase" placeholder="Enter passphrase..." autofocus>
|
|
<div id="unlock-error" class="error" style="margin-top: 4px"></div>
|
|
<div id="unlock-spinner" style="margin-top: 8px; display: none">
|
|
<span class="spinner"></span> <span class="secondary">Deriving key...</span>
|
|
</div>
|
|
</div>
|
|
<div class="actions" style="margin-top: 16px">
|
|
<button class="btn muted" id="btn-settings">settings</button>
|
|
<button class="btn-primary btn" id="btn-unlock">ENTER unlock</button>
|
|
</div>
|
|
`;
|
|
|
|
const input = container.querySelector('#passphrase') as HTMLInputElement;
|
|
const errorEl = container.querySelector('#unlock-error') as HTMLElement;
|
|
const spinnerEl = container.querySelector('#unlock-spinner') as HTMLElement;
|
|
const btnUnlock = container.querySelector('#btn-unlock') as HTMLButtonElement;
|
|
const btnSettings = container.querySelector('#btn-settings') as HTMLButtonElement;
|
|
|
|
async function doUnlock() {
|
|
const passphrase = input.value;
|
|
if (!passphrase) return;
|
|
|
|
errorEl.textContent = '';
|
|
spinnerEl.style.display = 'block';
|
|
btnUnlock.disabled = true;
|
|
|
|
const resp = await sendMessage({ type: 'unlock', passphrase });
|
|
spinnerEl.style.display = 'none';
|
|
btnUnlock.disabled = false;
|
|
|
|
if ('error' in resp) {
|
|
errorEl.textContent = resp.error;
|
|
input.value = '';
|
|
input.focus();
|
|
} else {
|
|
navigate({ view: 'list' });
|
|
}
|
|
}
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') doUnlock();
|
|
if (e.key === 'Escape') window.close();
|
|
});
|
|
|
|
btnUnlock.addEventListener('click', doUnlock);
|
|
btnSettings.addEventListener('click', () => navigate({ view: 'setup' }));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create entry list component**
|
|
|
|
Create `extension/src/popup/components/entry-list.ts`:
|
|
|
|
```typescript
|
|
import { sendMessage, navigate } from '../popup';
|
|
import type { ManifestEntry } from '../../shared/types';
|
|
|
|
export async function renderEntryList(container: HTMLElement) {
|
|
container.innerHTML = `
|
|
<div class="header">
|
|
<div class="brand">relicario</div>
|
|
<div class="status">🔓 unlocked</div>
|
|
</div>
|
|
<div class="search-bar">
|
|
<input type="text" id="search" placeholder="/ search...">
|
|
</div>
|
|
<div class="group-tabs" id="group-tabs"></div>
|
|
<div class="entry-list" id="entry-list">
|
|
<div class="empty">Loading...</div>
|
|
</div>
|
|
<div class="keyhints">↑↓ navigate · ENTER open · / search · + add</div>
|
|
`;
|
|
|
|
const searchInput = container.querySelector('#search') as HTMLInputElement;
|
|
const groupTabsEl = container.querySelector('#group-tabs') as HTMLElement;
|
|
const listEl = container.querySelector('#entry-list') as HTMLElement;
|
|
|
|
let entries: Array<{ id: string } & ManifestEntry> = [];
|
|
let selectedIndex = 0;
|
|
let activeGroup: string | undefined;
|
|
|
|
async function loadEntries() {
|
|
const resp = await sendMessage({ type: 'list_entries', group: activeGroup });
|
|
if ('error' in resp) return;
|
|
entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
|
|
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
selectedIndex = 0;
|
|
renderList();
|
|
renderGroupTabs();
|
|
}
|
|
|
|
function renderGroupTabs() {
|
|
// Get all groups from entries (fetch all, not filtered)
|
|
sendMessage({ type: 'list_entries' }).then((resp) => {
|
|
if ('error' in resp) return;
|
|
const allEntries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
|
|
const groups = new Set<string>();
|
|
allEntries.forEach((e) => { if (e.group) groups.add(e.group); });
|
|
|
|
let html = `<button class="group-tab ${!activeGroup ? 'active' : ''}" data-group="">all</button>`;
|
|
for (const g of Array.from(groups).sort()) {
|
|
html += `<button class="group-tab ${activeGroup === g ? 'active' : ''}" data-group="${g}">${g}</button>`;
|
|
}
|
|
groupTabsEl.innerHTML = html;
|
|
|
|
groupTabsEl.querySelectorAll('.group-tab').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const g = (btn as HTMLElement).dataset.group;
|
|
activeGroup = g || undefined;
|
|
loadEntries();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderList() {
|
|
if (entries.length === 0) {
|
|
listEl.innerHTML = '<div class="empty">No entries found.</div>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = entries
|
|
.map((e, i) => {
|
|
const domain = e.url ? extractDomain(e.url) : '';
|
|
const meta = [e.username, domain].filter(Boolean).join(' · ');
|
|
return `<div class="entry-row ${i === selectedIndex ? 'selected' : ''}" data-index="${i}" data-id="${e.id}">
|
|
<div class="entry-name">${escapeHtml(e.name)}</div>
|
|
<div class="entry-meta">${escapeHtml(meta)}</div>
|
|
</div>`;
|
|
})
|
|
.join('');
|
|
|
|
listEl.querySelectorAll('.entry-row').forEach((row) => {
|
|
row.addEventListener('click', () => {
|
|
const id = (row as HTMLElement).dataset.id!;
|
|
navigate({ view: 'detail', entryId: id });
|
|
});
|
|
});
|
|
}
|
|
|
|
function handleSearch() {
|
|
const query = searchInput.value.trim();
|
|
if (!query) {
|
|
loadEntries();
|
|
return;
|
|
}
|
|
sendMessage({ type: 'search_entries', query }).then((resp) => {
|
|
if ('error' in resp) return;
|
|
entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
|
|
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
selectedIndex = 0;
|
|
renderList();
|
|
});
|
|
}
|
|
|
|
searchInput.addEventListener('input', handleSearch);
|
|
|
|
document.addEventListener('keydown', function handler(e) {
|
|
// Clean up when we navigate away
|
|
if (!container.isConnected) {
|
|
document.removeEventListener('keydown', handler);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (selectedIndex < entries.length - 1) {
|
|
selectedIndex++;
|
|
renderList();
|
|
}
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (selectedIndex > 0) {
|
|
selectedIndex--;
|
|
renderList();
|
|
}
|
|
} else if (e.key === 'Enter' && document.activeElement !== searchInput) {
|
|
if (entries[selectedIndex]) {
|
|
navigate({ view: 'detail', entryId: entries[selectedIndex].id });
|
|
}
|
|
} else if (e.key === '/' && document.activeElement !== searchInput) {
|
|
e.preventDefault();
|
|
searchInput.focus();
|
|
} else if (e.key === '+' && document.activeElement !== searchInput) {
|
|
e.preventDefault();
|
|
navigate({ view: 'add' });
|
|
} else if (e.key === 'Escape') {
|
|
if (document.activeElement === searchInput) {
|
|
searchInput.value = '';
|
|
searchInput.blur();
|
|
loadEntries();
|
|
} else {
|
|
window.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
await loadEntries();
|
|
}
|
|
|
|
function extractDomain(url: string): string {
|
|
try {
|
|
return new URL(url).hostname;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create entry detail component**
|
|
|
|
Create `extension/src/popup/components/entry-detail.ts`:
|
|
|
|
```typescript
|
|
import { sendMessage, navigate } from '../popup';
|
|
import type { Entry } from '../../shared/types';
|
|
|
|
export async function renderEntryDetail(container: HTMLElement, entryId: string) {
|
|
container.innerHTML = `
|
|
<div class="detail-header">
|
|
<span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
|
|
<span class="muted" id="entry-group"></span>
|
|
</div>
|
|
<div class="detail-title" id="entry-name">Loading...</div>
|
|
<div id="entry-fields" style="margin-top: 12px"></div>
|
|
<div class="keyhints">f fill · e edit · d delete</div>
|
|
`;
|
|
|
|
const backBtn = container.querySelector('#back') as HTMLElement;
|
|
const nameEl = container.querySelector('#entry-name') as HTMLElement;
|
|
const groupEl = container.querySelector('#entry-group') as HTMLElement;
|
|
const fieldsEl = container.querySelector('#entry-fields') as HTMLElement;
|
|
|
|
backBtn.addEventListener('click', () => navigate({ view: 'list' }));
|
|
|
|
const resp = await sendMessage({ type: 'get_entry', id: entryId });
|
|
if ('error' in resp) {
|
|
nameEl.textContent = 'Error loading entry';
|
|
return;
|
|
}
|
|
|
|
const entry = (resp as { ok: true; entry: Entry }).entry;
|
|
nameEl.textContent = entry.name;
|
|
groupEl.textContent = entry.group ?? '';
|
|
|
|
let fieldsHtml = '';
|
|
|
|
if (entry.url) {
|
|
fieldsHtml += `
|
|
<div class="field">
|
|
<div class="label">URL</div>
|
|
<div class="secondary">${escapeHtml(entry.url)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
if (entry.username) {
|
|
fieldsHtml += `
|
|
<div class="field">
|
|
<div class="label">USERNAME</div>
|
|
<div class="field-value">
|
|
<span>${escapeHtml(entry.username)}</span>
|
|
<span class="hint" data-copy="username">c copy</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
fieldsHtml += `
|
|
<div class="field">
|
|
<div class="label">PASSWORD</div>
|
|
<div class="field-value">
|
|
<span id="pw-display">••••••••••</span>
|
|
<span class="hint" data-copy="password">p copy</span>
|
|
</div>
|
|
</div>`;
|
|
|
|
if (entry.totp_secret) {
|
|
fieldsHtml += `
|
|
<div class="field">
|
|
<div class="label">TOTP</div>
|
|
<div class="field-value">
|
|
<span class="totp-code" id="totp-code">------</span>
|
|
<span class="hint"><span id="totp-remaining">--</span>s · <span data-copy="totp">t copy</span></span>
|
|
</div>
|
|
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill"></div></div>
|
|
</div>`;
|
|
}
|
|
|
|
if (entry.notes) {
|
|
fieldsHtml += `
|
|
<div class="field">
|
|
<div class="label">NOTES</div>
|
|
<div class="secondary" style="white-space: pre-wrap">${escapeHtml(entry.notes)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
fieldsEl.innerHTML = fieldsHtml;
|
|
|
|
// ─── TOTP countdown ────────────────────────────────────────────────────
|
|
|
|
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
if (entry.totp_secret) {
|
|
async function updateTotp() {
|
|
const totpResp = await sendMessage({ type: 'get_totp', id: entryId });
|
|
if ('error' in totpResp) return;
|
|
const { code, remaining_seconds } = totpResp as { ok: true; code: string; remaining_seconds: number };
|
|
|
|
const codeEl = container.querySelector('#totp-code');
|
|
const remainEl = container.querySelector('#totp-remaining');
|
|
const barEl = container.querySelector('#totp-bar-fill') as HTMLElement | null;
|
|
|
|
if (codeEl) codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
|
if (remainEl) remainEl.textContent = String(remaining_seconds);
|
|
if (barEl) barEl.style.width = `${(remaining_seconds / 30) * 100}%`;
|
|
}
|
|
|
|
await updateTotp();
|
|
totpInterval = setInterval(updateTotp, 1000);
|
|
}
|
|
|
|
// ─── Copy handlers ────────────────────────────────────────────────────
|
|
|
|
async function copyToClipboard(text: string) {
|
|
await navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
container.querySelectorAll('[data-copy]').forEach((el) => {
|
|
el.addEventListener('click', async () => {
|
|
const field = (el as HTMLElement).dataset.copy;
|
|
if (field === 'username' && entry.username) {
|
|
await copyToClipboard(entry.username);
|
|
} else if (field === 'password') {
|
|
await copyToClipboard(entry.password);
|
|
} else if (field === 'totp') {
|
|
const totpResp = await sendMessage({ type: 'get_totp', id: entryId });
|
|
if (!('error' in totpResp)) {
|
|
await copyToClipboard((totpResp as { ok: true; code: string }).code);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Keyboard shortcuts ───────────────────────────────────────────────
|
|
|
|
document.addEventListener('keydown', function handler(e) {
|
|
if (!container.isConnected) {
|
|
if (totpInterval) clearInterval(totpInterval);
|
|
document.removeEventListener('keydown', handler);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Escape') {
|
|
navigate({ view: 'list' });
|
|
} else if (e.key === 'c') {
|
|
if (entry.username) copyToClipboard(entry.username);
|
|
} else if (e.key === 'p') {
|
|
copyToClipboard(entry.password);
|
|
} else if (e.key === 't' && entry.totp_secret) {
|
|
sendMessage({ type: 'get_totp', id: entryId }).then((resp) => {
|
|
if (!('error' in resp)) {
|
|
copyToClipboard((resp as { ok: true; code: string }).code);
|
|
}
|
|
});
|
|
} else if (e.key === 'e') {
|
|
navigate({ view: 'edit', entryId });
|
|
} else if (e.key === 'f') {
|
|
// Autofill active tab
|
|
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
|
if (!tabs[0]?.id) return;
|
|
const creds = await sendMessage({ type: 'get_credentials', id: entryId });
|
|
if ('error' in creds) return;
|
|
const { username, password } = creds as { ok: true; username: string; password: string };
|
|
chrome.tabs.sendMessage(tabs[0].id, { type: 'fill_credentials', username, password });
|
|
});
|
|
} else if (e.key === 'd') {
|
|
showDeleteConfirm(container, entryId, entry.name);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showDeleteConfirm(container: HTMLElement, entryId: string, name: string) {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'confirm-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="confirm-box">
|
|
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
|
|
<p class="secondary" style="margin-top: 4px; font-size: 11px">This commits a removal to the vault.</p>
|
|
<div class="actions" style="margin-top: 12px">
|
|
<button class="btn" id="confirm-no">No</button>
|
|
<button class="btn btn-danger" id="confirm-yes">Yes, delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(overlay);
|
|
|
|
overlay.querySelector('#confirm-no')!.addEventListener('click', () => overlay.remove());
|
|
overlay.querySelector('#confirm-yes')!.addEventListener('click', async () => {
|
|
await sendMessage({ type: 'delete_entry', id: entryId });
|
|
navigate({ view: 'list' });
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create entry form component (add/edit)**
|
|
|
|
Create `extension/src/popup/components/entry-form.ts`:
|
|
|
|
```typescript
|
|
import { sendMessage, navigate } from '../popup';
|
|
import type { Entry } from '../../shared/types';
|
|
|
|
export async function renderEntryForm(container: HTMLElement, entryId: string | null) {
|
|
const isEdit = entryId !== null;
|
|
let existing: Entry | null = null;
|
|
|
|
if (isEdit) {
|
|
const resp = await sendMessage({ type: 'get_entry', id: entryId });
|
|
if ('error' in resp) {
|
|
container.innerHTML = '<div class="error">Failed to load entry</div>';
|
|
return;
|
|
}
|
|
existing = (resp as { ok: true; entry: Entry }).entry;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="detail-header">
|
|
<span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
|
|
<span class="brand">${isEdit ? 'edit' : 'add'}</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">NAME *</div>
|
|
<input type="text" id="f-name" value="${escapeAttr(existing?.name ?? '')}" autofocus>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">URL</div>
|
|
<input type="text" id="f-url" value="${escapeAttr(existing?.url ?? '')}" placeholder="https://...">
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">USERNAME</div>
|
|
<input type="text" id="f-username" value="${escapeAttr(existing?.username ?? '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">PASSWORD</div>
|
|
<div style="display:flex;gap:4px">
|
|
<input type="text" id="f-password" value="${escapeAttr(existing?.password ?? '')}" style="flex:1">
|
|
<button class="btn" id="btn-generate" title="Generate">gen</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">TOTP SECRET</div>
|
|
<input type="text" id="f-totp" value="${escapeAttr(existing?.totp_secret ?? '')}" placeholder="Base32 encoded">
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">GROUP</div>
|
|
<input type="text" id="f-group" value="${escapeAttr(existing?.group ?? '')}" placeholder="personal, work, ...">
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">NOTES</div>
|
|
<textarea id="f-notes" rows="3">${escapeHtml(existing?.notes ?? '')}</textarea>
|
|
</div>
|
|
<div id="form-error" class="error"></div>
|
|
<div class="actions">
|
|
<button class="btn" id="btn-cancel">Cancel</button>
|
|
<button class="btn btn-primary" id="btn-save">Save</button>
|
|
</div>
|
|
`;
|
|
|
|
const backBtn = container.querySelector('#back') as HTMLElement;
|
|
const btnCancel = container.querySelector('#btn-cancel') as HTMLButtonElement;
|
|
const btnSave = container.querySelector('#btn-save') as HTMLButtonElement;
|
|
const btnGenerate = container.querySelector('#btn-generate') as HTMLButtonElement;
|
|
const errorEl = container.querySelector('#form-error') as HTMLElement;
|
|
|
|
function goBack() {
|
|
if (isEdit) {
|
|
navigate({ view: 'detail', entryId: entryId! });
|
|
} else {
|
|
navigate({ view: 'list' });
|
|
}
|
|
}
|
|
|
|
backBtn.addEventListener('click', goBack);
|
|
btnCancel.addEventListener('click', goBack);
|
|
|
|
btnGenerate.addEventListener('click', async () => {
|
|
const resp = await sendMessage({ type: 'is_unlocked' }); // Just to trigger WASM load
|
|
// Generate locally via service worker — but we can just use crypto.getRandomValues
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+';
|
|
const array = new Uint8Array(24);
|
|
crypto.getRandomValues(array);
|
|
const password = Array.from(array, (b) => chars[b % chars.length]).join('');
|
|
(container.querySelector('#f-password') as HTMLInputElement).value = password;
|
|
});
|
|
|
|
btnSave.addEventListener('click', async () => {
|
|
const name = (container.querySelector('#f-name') as HTMLInputElement).value.trim();
|
|
if (!name) {
|
|
errorEl.textContent = 'Name is required';
|
|
return;
|
|
}
|
|
|
|
const entry = {
|
|
name,
|
|
url: (container.querySelector('#f-url') as HTMLInputElement).value.trim() || undefined,
|
|
username: (container.querySelector('#f-username') as HTMLInputElement).value.trim() || undefined,
|
|
password: (container.querySelector('#f-password') as HTMLInputElement).value,
|
|
totp_secret: (container.querySelector('#f-totp') as HTMLInputElement).value.trim() || undefined,
|
|
group: (container.querySelector('#f-group') as HTMLInputElement).value.trim() || undefined,
|
|
notes: (container.querySelector('#f-notes') as HTMLTextAreaElement).value.trim() || undefined,
|
|
};
|
|
|
|
btnSave.disabled = true;
|
|
errorEl.textContent = '';
|
|
|
|
let resp;
|
|
if (isEdit) {
|
|
resp = await sendMessage({ type: 'update_entry', id: entryId!, entry });
|
|
} else {
|
|
resp = await sendMessage({ type: 'add_entry', entry });
|
|
}
|
|
|
|
if ('error' in resp) {
|
|
errorEl.textContent = resp.error;
|
|
btnSave.disabled = false;
|
|
return;
|
|
}
|
|
|
|
if (isEdit) {
|
|
navigate({ view: 'detail', entryId: entryId! });
|
|
} else {
|
|
navigate({ view: 'list' });
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', function handler(e) {
|
|
if (!container.isConnected) {
|
|
document.removeEventListener('keydown', handler);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') goBack();
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create setup wizard component**
|
|
|
|
Create `extension/src/popup/components/setup-wizard.ts`:
|
|
|
|
```typescript
|
|
import { sendMessage, navigate } from '../popup';
|
|
import type { VaultConfig } from '../../shared/types';
|
|
|
|
export function renderSetupWizard(container: HTMLElement) {
|
|
let step = 1;
|
|
let config: Partial<VaultConfig> = { hostType: 'gitea' };
|
|
let imageBase64: string | null = null;
|
|
|
|
function render() {
|
|
container.innerHTML = `
|
|
<div class="brand">relicario setup</div>
|
|
<div class="wizard-step">step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}</div>
|
|
<div class="progress-bar"><div class="progress-bar-fill" style="width: ${(step / 3) * 100}%"></div></div>
|
|
<div id="wizard-content"></div>
|
|
<div id="wizard-error" class="error" style="margin-top: 8px"></div>
|
|
<div class="actions" id="wizard-actions"></div>
|
|
`;
|
|
const content = container.querySelector('#wizard-content') as HTMLElement;
|
|
const actions = container.querySelector('#wizard-actions') as HTMLElement;
|
|
|
|
if (step === 1) renderStep1(content, actions);
|
|
else if (step === 2) renderStep2(content, actions);
|
|
else if (step === 3) renderStep3(content, actions);
|
|
}
|
|
|
|
function renderStep1(content: HTMLElement, actions: HTMLElement) {
|
|
content.innerHTML = `
|
|
<div class="form-group">
|
|
<div class="label">HOST TYPE</div>
|
|
<div class="host-toggle">
|
|
<button class="group-tab ${config.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">gitea</button>
|
|
<button class="group-tab ${config.hostType === 'github' ? 'active' : ''}" data-host="github">github</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">HOST URL</div>
|
|
<input type="text" id="w-host" value="${config.hostUrl ?? ''}" placeholder="https://git.example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">REPO PATH</div>
|
|
<input type="text" id="w-repo" value="${config.repoPath ?? ''}" placeholder="owner/repo">
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="label">API TOKEN</div>
|
|
<input type="password" id="w-token" value="${config.apiToken ?? ''}" placeholder="Paste token here">
|
|
</div>
|
|
`;
|
|
actions.innerHTML = `<button class="btn btn-primary" id="btn-next">Next →</button>`;
|
|
|
|
content.querySelectorAll('[data-host]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
config.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
|
render();
|
|
});
|
|
});
|
|
|
|
actions.querySelector('#btn-next')!.addEventListener('click', () => {
|
|
config.hostUrl = (content.querySelector('#w-host') as HTMLInputElement).value.trim();
|
|
config.repoPath = (content.querySelector('#w-repo') as HTMLInputElement).value.trim();
|
|
config.apiToken = (content.querySelector('#w-token') as HTMLInputElement).value.trim();
|
|
|
|
const errorEl = container.querySelector('#wizard-error') as HTMLElement;
|
|
if (!config.hostUrl || !config.repoPath || !config.apiToken) {
|
|
errorEl.textContent = 'All fields are required';
|
|
return;
|
|
}
|
|
step = 2;
|
|
render();
|
|
});
|
|
}
|
|
|
|
function renderStep2(content: HTMLElement, actions: HTMLElement) {
|
|
content.innerHTML = `
|
|
<div class="form-group">
|
|
<div class="label">REFERENCE IMAGE</div>
|
|
<p class="secondary" style="margin-bottom: 8px; font-size: 11px">
|
|
Upload the JPEG with your embedded secret. Stored locally, never sent to the server.
|
|
</p>
|
|
<input type="file" id="w-image" accept="image/jpeg">
|
|
<div id="image-status" class="secondary" style="margin-top: 4px; font-size: 11px">
|
|
${imageBase64 ? '✓ Image loaded' : 'No image selected'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
actions.innerHTML = `
|
|
<button class="btn" id="btn-back">← Back</button>
|
|
<button class="btn btn-primary" id="btn-next">Next →</button>
|
|
`;
|
|
|
|
const fileInput = content.querySelector('#w-image') as HTMLInputElement;
|
|
const statusEl = content.querySelector('#image-status') as HTMLElement;
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
const file = fileInput.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const dataUrl = reader.result as string;
|
|
// Strip the data:image/jpeg;base64, prefix
|
|
imageBase64 = dataUrl.split(',')[1];
|
|
statusEl.textContent = `✓ ${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 1; render(); });
|
|
actions.querySelector('#btn-next')!.addEventListener('click', () => {
|
|
const errorEl = container.querySelector('#wizard-error') as HTMLElement;
|
|
if (!imageBase64) {
|
|
errorEl.textContent = 'Please select a reference image';
|
|
return;
|
|
}
|
|
step = 3;
|
|
render();
|
|
});
|
|
}
|
|
|
|
function renderStep3(content: HTMLElement, actions: HTMLElement) {
|
|
content.innerHTML = `
|
|
<div class="form-group">
|
|
<div class="label">TEST UNLOCK</div>
|
|
<p class="secondary" style="margin-bottom: 8px; font-size: 11px">
|
|
Enter your passphrase to verify everything works.
|
|
</p>
|
|
<input type="password" id="w-passphrase" placeholder="Passphrase" autofocus>
|
|
<div id="test-spinner" style="margin-top: 8px; display: none">
|
|
<span class="spinner"></span> <span class="secondary">Testing...</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
actions.innerHTML = `
|
|
<button class="btn" id="btn-back">← Back</button>
|
|
<button class="btn btn-primary" id="btn-finish">Test & Save</button>
|
|
`;
|
|
|
|
actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 2; render(); });
|
|
actions.querySelector('#btn-finish')!.addEventListener('click', async () => {
|
|
const passphrase = (content.querySelector('#w-passphrase') as HTMLInputElement).value;
|
|
const errorEl = container.querySelector('#wizard-error') as HTMLElement;
|
|
const spinnerEl = content.querySelector('#test-spinner') as HTMLElement;
|
|
|
|
if (!passphrase) {
|
|
errorEl.textContent = 'Enter your passphrase';
|
|
return;
|
|
}
|
|
|
|
errorEl.textContent = '';
|
|
spinnerEl.style.display = 'block';
|
|
|
|
// Save config first
|
|
await sendMessage({
|
|
type: 'save_setup',
|
|
config: config as VaultConfig,
|
|
imageBase64: imageBase64!,
|
|
});
|
|
|
|
// Try to unlock
|
|
const resp = await sendMessage({ type: 'unlock', passphrase });
|
|
spinnerEl.style.display = 'none';
|
|
|
|
if ('error' in resp) {
|
|
errorEl.textContent = `Test failed: ${resp.error}`;
|
|
return;
|
|
}
|
|
|
|
navigate({ view: 'list' });
|
|
});
|
|
}
|
|
|
|
render();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Install npm dependencies**
|
|
|
|
```bash
|
|
cd extension && npm install
|
|
```
|
|
|
|
Expected: `node_modules/` created with TypeScript, webpack, etc.
|
|
|
|
- [ ] **Step 8: Build the extension**
|
|
|
|
```bash
|
|
cd extension && npm run build
|
|
```
|
|
|
|
Expected: `dist/` directory with `service-worker.js`, `popup.js`, `content.js`, `popup.html`, `styles.css`, `manifest.json`, WASM files, and icons.
|
|
|
|
If build fails, fix any TypeScript errors and retry.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add extension/src/popup/ extension/src/service-worker/index.ts
|
|
git commit -m "feat: add popup UI with state machine, all components, and setup wizard"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Content Script — Form Detection and Autofill
|
|
|
|
**Files:**
|
|
- Create: `extension/src/content/detector.ts`
|
|
- Create: `extension/src/content/fill.ts`
|
|
- Create: `extension/src/content/icon.ts`
|
|
|
|
- [ ] **Step 1: Create the form detector and content script entry point**
|
|
|
|
Create `extension/src/content/detector.ts`:
|
|
|
|
```typescript
|
|
import { injectFieldIcons } from './icon';
|
|
import { setupFillListener } from './fill';
|
|
|
|
function detectLoginForms(): { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] {
|
|
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
|
const results: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] = [];
|
|
|
|
for (const pwField of passwordFields) {
|
|
if (!pwField.offsetParent) continue; // skip hidden fields
|
|
|
|
const usernameField = findUsernameField(pwField);
|
|
results.push({ passwordField: pwField, usernameField });
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function findUsernameField(passwordField: HTMLInputElement): HTMLInputElement | null {
|
|
const form = passwordField.closest('form');
|
|
const scope = form ?? document;
|
|
|
|
// Priority 1: autocomplete="username"
|
|
const byAutocompleteUser = scope.querySelector<HTMLInputElement>('input[autocomplete="username"]');
|
|
if (byAutocompleteUser) return byAutocompleteUser;
|
|
|
|
// Priority 2: autocomplete="email"
|
|
const byAutocompleteEmail = scope.querySelector<HTMLInputElement>('input[autocomplete="email"]');
|
|
if (byAutocompleteEmail) return byAutocompleteEmail;
|
|
|
|
// Priority 3: type="email"
|
|
const byTypeEmail = scope.querySelector<HTMLInputElement>('input[type="email"]');
|
|
if (byTypeEmail) return byTypeEmail;
|
|
|
|
// Priority 4: name matching pattern
|
|
const byNamePattern = scope.querySelector<HTMLInputElement>(
|
|
'input[name*="user" i], input[name*="email" i], input[name*="login" i], input[name*="account" i]'
|
|
);
|
|
if (byNamePattern && byNamePattern !== passwordField) return byNamePattern;
|
|
|
|
// Priority 5: nearest preceding text input in same form
|
|
if (form) {
|
|
const inputs = Array.from(form.querySelectorAll<HTMLInputElement>('input[type="text"], input[type="email"], input:not([type])'));
|
|
const pwIndex = Array.from(form.querySelectorAll('input')).indexOf(passwordField);
|
|
for (let i = inputs.length - 1; i >= 0; i--) {
|
|
const inp = inputs[i];
|
|
const inpIndex = Array.from(form.querySelectorAll('input')).indexOf(inp);
|
|
if (inpIndex < pwIndex) return inp;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── Init ───────────────────────────────────────────────────────────────────
|
|
|
|
function init() {
|
|
const forms = detectLoginForms();
|
|
if (forms.length > 0) {
|
|
injectFieldIcons(forms);
|
|
}
|
|
setupFillListener();
|
|
}
|
|
|
|
// Run detection after DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
// Re-detect on DOM changes (for SPAs that load forms dynamically)
|
|
const observer = new MutationObserver(() => {
|
|
const forms = detectLoginForms();
|
|
if (forms.length > 0) {
|
|
injectFieldIcons(forms);
|
|
}
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
```
|
|
|
|
- [ ] **Step 2: Create the fill module**
|
|
|
|
Create `extension/src/content/fill.ts`:
|
|
|
|
```typescript
|
|
export function setupFillListener() {
|
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
if (message.type === 'fill_credentials') {
|
|
fillFields(message.username, message.password);
|
|
sendResponse({ ok: true });
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
export function fillFields(username: string, password: string) {
|
|
const passwordField = document.querySelector<HTMLInputElement>('input[type="password"]');
|
|
if (!passwordField) return;
|
|
|
|
// Fill password
|
|
setInputValue(passwordField, password);
|
|
|
|
// Find and fill username
|
|
const form = passwordField.closest('form');
|
|
const scope = form ?? document;
|
|
|
|
const usernameField =
|
|
scope.querySelector<HTMLInputElement>('input[autocomplete="username"]') ??
|
|
scope.querySelector<HTMLInputElement>('input[autocomplete="email"]') ??
|
|
scope.querySelector<HTMLInputElement>('input[type="email"]') ??
|
|
scope.querySelector<HTMLInputElement>(
|
|
'input[name*="user" i], input[name*="email" i], input[name*="login" i]'
|
|
);
|
|
|
|
if (usernameField && username) {
|
|
setInputValue(usernameField, username);
|
|
}
|
|
|
|
// Focus submit button or password field
|
|
const submitBtn = form?.querySelector<HTMLButtonElement>('button[type="submit"], input[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.focus();
|
|
} else {
|
|
passwordField.focus();
|
|
}
|
|
}
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
// Use native setter to bypass React/Vue/Angular controlled component checks
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
if (nativeSetter) {
|
|
nativeSetter.call(input, value);
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
|
|
// Dispatch events that frameworks listen for
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create the field icon module**
|
|
|
|
Create `extension/src/content/icon.ts`:
|
|
|
|
```typescript
|
|
import type { ManifestEntry } from '../shared/types';
|
|
import { fillFields } from './fill';
|
|
|
|
const ICON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="1" y="1" width="14" height="14" rx="2" fill="#1f6feb" fill-opacity="0.9"/>
|
|
<text x="8" y="11.5" text-anchor="middle" fill="white" font-size="9" font-family="monospace" font-weight="bold">id</text>
|
|
</svg>`;
|
|
|
|
const injectedFields = new WeakSet<HTMLInputElement>();
|
|
|
|
export function injectFieldIcons(
|
|
forms: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[]
|
|
) {
|
|
for (const { passwordField, usernameField } of forms) {
|
|
if (injectedFields.has(passwordField)) continue;
|
|
injectedFields.add(passwordField);
|
|
|
|
const icon = document.createElement('div');
|
|
icon.innerHTML = ICON_SVG;
|
|
icon.style.cssText = `
|
|
position: absolute;
|
|
right: 8px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
cursor: pointer;
|
|
z-index: 10000;
|
|
opacity: 0.8;
|
|
transition: opacity 0.2s;
|
|
`;
|
|
icon.addEventListener('mouseenter', () => { icon.style.opacity = '1'; });
|
|
icon.addEventListener('mouseleave', () => { icon.style.opacity = '0.8'; });
|
|
|
|
// Wrap the password field in a relative container if needed
|
|
const parent = passwordField.parentElement;
|
|
if (parent) {
|
|
const computed = getComputedStyle(parent);
|
|
if (computed.position === 'static') {
|
|
parent.style.position = 'relative';
|
|
}
|
|
parent.appendChild(icon);
|
|
}
|
|
|
|
icon.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const url = window.location.href;
|
|
const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', url });
|
|
|
|
if ('error' in resp || !resp.candidates || resp.candidates.length === 0) {
|
|
// No matches — open popup
|
|
return;
|
|
}
|
|
|
|
const candidates = resp.candidates as Array<{ id: string } & ManifestEntry>;
|
|
|
|
if (candidates.length === 1) {
|
|
// Single match — fill immediately
|
|
const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidates[0].id });
|
|
if (!('error' in creds)) {
|
|
fillFields(creds.username, creds.password);
|
|
}
|
|
} else {
|
|
// Multiple matches — show picker
|
|
showPicker(icon, candidates, usernameField, passwordField);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function showPicker(
|
|
anchor: HTMLElement,
|
|
candidates: Array<{ id: string } & ManifestEntry>,
|
|
usernameField: HTMLInputElement | null,
|
|
passwordField: HTMLInputElement
|
|
) {
|
|
// Remove any existing picker
|
|
document.querySelectorAll('.relicario-picker').forEach((el) => el.remove());
|
|
|
|
const picker = document.createElement('div');
|
|
picker.className = 'relicario-picker';
|
|
picker.style.cssText = `
|
|
position: absolute;
|
|
right: 0;
|
|
top: 100%;
|
|
margin-top: 4px;
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 4px;
|
|
min-width: 200px;
|
|
z-index: 10001;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
`;
|
|
|
|
for (const candidate of candidates) {
|
|
const row = document.createElement('div');
|
|
row.style.cssText = `
|
|
padding: 6px 10px;
|
|
cursor: pointer;
|
|
color: #c9d1d9;
|
|
border-bottom: 1px solid #21262d;
|
|
`;
|
|
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
|
|
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
|
|
row.innerHTML = `
|
|
<div>${escapeHtml(candidate.name)}</div>
|
|
<div style="color:#484f58;font-size:10px">${escapeHtml(candidate.username ?? '')}</div>
|
|
`;
|
|
row.addEventListener('click', async () => {
|
|
const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidate.id });
|
|
if (!('error' in creds)) {
|
|
fillFields(creds.username, creds.password);
|
|
}
|
|
picker.remove();
|
|
});
|
|
picker.appendChild(row);
|
|
}
|
|
|
|
anchor.parentElement?.appendChild(picker);
|
|
|
|
// Close picker on outside click
|
|
document.addEventListener('click', function handler(e) {
|
|
if (!picker.contains(e.target as Node) && e.target !== anchor) {
|
|
picker.remove();
|
|
document.removeEventListener('click', handler);
|
|
}
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Update webpack config entry for content script**
|
|
|
|
The webpack config should bundle all three content script files together. Update the content entry in `extension/webpack.config.js`:
|
|
|
|
The entry `content: './src/content/detector.ts'` already imports `icon.ts` and `fill.ts`, so webpack will bundle them together. No change needed.
|
|
|
|
- [ ] **Step 5: Build and verify**
|
|
|
|
```bash
|
|
cd extension && npm run build
|
|
```
|
|
|
|
Expected: `dist/content.js` contains the bundled content script.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add extension/src/content/
|
|
git commit -m "feat: add content script with form detection, field icon, and autofill"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Build Integration and Manual Test
|
|
|
|
**Files:**
|
|
- Modify: `extension/webpack.config.js` (if needed)
|
|
- Create: `Makefile` (optional convenience)
|
|
|
|
- [ ] **Step 1: Full build from scratch**
|
|
|
|
```bash
|
|
# Build WASM
|
|
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
|
|
|
|
# Install deps and build extension
|
|
cd extension && npm install && npm run build
|
|
```
|
|
|
|
Expected: `extension/dist/` contains all files needed to load as an unpacked Chrome extension.
|
|
|
|
- [ ] **Step 2: Note the WASM binary size**
|
|
|
|
```bash
|
|
ls -lh extension/wasm/relicario_wasm_bg.wasm
|
|
ls -lh extension/dist/relicario_wasm_bg.wasm
|
|
```
|
|
|
|
Record the size for reference. If >2 MB uncompressed, consider optimizing later.
|
|
|
|
- [ ] **Step 3: Load in Chrome**
|
|
|
|
Open `chrome://extensions/`, enable Developer Mode, click "Load unpacked", select `extension/dist/`. Verify:
|
|
- Extension appears in the toolbar
|
|
- Clicking the icon opens the popup
|
|
- Setup wizard renders correctly
|
|
- No console errors in the service worker (inspect via "Service Worker" link on the extensions page)
|
|
|
|
- [ ] **Step 4: Test the full flow manually**
|
|
|
|
1. Setup wizard: enter Gitea/GitHub host, repo, token, upload reference image
|
|
2. Test unlock: enter passphrase, verify spinner + successful unlock
|
|
3. Entry list: verify entries load from remote vault
|
|
4. Add entry: create a new entry with a group
|
|
5. Entry detail: verify TOTP countdown (if entry has TOTP), copy shortcuts
|
|
6. Autofill: navigate to a login page, verify field icon appears, click to fill
|
|
7. Lock/re-unlock: close popup, wait, reopen — verify re-unlock is required
|
|
|
|
- [ ] **Step 5: Fix any issues found during manual testing**
|
|
|
|
Address any bugs or UX issues discovered during manual testing. Each fix gets its own commit.
|
|
|
|
- [ ] **Step 6: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: complete WASM + Chrome MV3 extension build"
|
|
```
|
|
|
|
---
|
|
|
|
## Task Summary
|
|
|
|
| Task | Description | Dependencies |
|
|
|------|-------------|--------------|
|
|
| 0 | Add heavy comments to existing Rust code | None |
|
|
| 1 | Add `group` field to core data model | Task 0 |
|
|
| 2 | Create `relicario-wasm` crate | Task 1 |
|
|
| 3 | Extension scaffolding | Task 2 |
|
|
| 4 | Shared types and messages | Task 3 |
|
|
| 5 | Git API layer | Task 4 |
|
|
| 6 | Service worker — vault operations | Task 4, 5 |
|
|
| 7 | Service worker — main entry point | Task 5, 6 |
|
|
| 8 | Popup styles | Task 3 |
|
|
| 9 | Popup state machine and components | Task 4, 7, 8 |
|
|
| 10 | Content script | Task 4 |
|
|
| 11 | Build integration and manual test | All |
|
|
|
|
Tasks 3-4, 5, 8 can be parallelized. Tasks 6-7 are sequential. Task 9 depends on 7+8. Task 10 is independent of 9. Task 11 is the final integration.
|