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