diff --git a/AGENTS.md b/AGENTS.md index 84f9100..123d61a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,16 +17,18 @@ src/ │ ├── EntryList.svelte # Credential entries grid │ ├── EntryForm.svelte # Create/edit credential form │ ├── EntryDetail.svelte # View single entry (copy password/username) -│ ├── PasswordGenerator.svelte# Configurable password generator -│ └── ImportExport.svelte # JSON import/export with merge/replace +│ ├── ImportExport.svelte # JSON import/export with merge/replace +│ └── SettingsDialog.svelte # Auto-lock and tab-switch settings ├── lib/ │ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator │ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores │ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation +│ ├── autofocus.js # Svelte action for autofocus on mount │ └── stores/ │ ├── app.svelte.js # AppStore: $state(isUnlocked, encryptionKey, salt) │ ├── security.svelte.js # Auto-lock timer, visibility change, beforeunload cleanup -│ └── search.svelte.js # Reactive search state +│ ├── search.svelte.js # Reactive search state +│ └── settings.svelte.js # Reactive settings (auto-lock minutes, tab-switch lock) ``` ## Key Design Decisions @@ -37,6 +39,7 @@ src/ - **Single-file build** — `vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon. - **IndexedDB via `idb`** — Three stores: `entries`, `groups`, `meta`. Only `encryptedPassword` is encrypted at rest; titles, usernames, URLs, and notes are plaintext for searchability. - **PBKDF2 key derivation** — 600,000 iterations, SHA-256, 256-bit AES-GCM key. +- **`GROUP_COLORS` exported from schema.js** — Shared between `createGroup()` and `Sidebar.svelte`. ## Encryption Flow @@ -59,14 +62,26 @@ Password verification uses a test payload (random string encrypted at vault crea ## Testing - Framework: **Vitest** with **jsdom** environment -- Test setup: `tests/setup.js` (fake-indexeddb polyfill) -- Test files: `tests/lib/stores/*.test.js` +- Test setup: `tests/setup.js` (fake-indexeddb polyfill, Web Crypto API polyfill) +- Test files: + - `tests/lib/crypto/crypto.test.js` — Password generation, key derivation, encrypt/decrypt, verify, test payload, base64 utils + - `tests/lib/models/schema.test.js` — ID generation, entry/group CRUD, validation, trash group + - `tests/lib/storage/db.test.js` — Vault meta, settings, groups CRUD, entries CRUD, search, trash, export/import + - `tests/lib/stores/app.test.js` — AppStore state and lockVault + - `tests/lib/stores/search.test.js` — SearchStore query, group filter, clear + - `tests/lib/stores/settings.test.js` — SettingsStore defaults, load, save + - `tests/lib/stores/security.test.js` — Auto-lock timer, visibility change, beforeunload, activity reset - Run with `npm run test` or `npm run test:run` ## Security Notes - Only `encryptedPassword` is encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB. - `testPlaintext` for password verification is stored unencrypted in the `meta` store. -- Auto-lock triggers on tab visibility change and 5-minute inactivity. +- Auto-lock triggers on tab visibility change and configurable inactivity timer (default 5 min). - Clipboard auto-clears after 15 seconds. - No browser fingerprinting or anti-keylogger protections. + +## Known Bug Fixes + +- `base64ToUint8Array` was used in `db.js` `importAll()` but never imported — now imported from `crypto.js`. +- `handlePermanentDelete()` in `EntryDetail.svelte` called `moveToTrash()` + `emptyTrash()` (wiping ALL trash) — now uses `deleteEntry()` directly. diff --git a/README.md b/README.md index 7dc6f46..04e4a30 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ An offline-first password manager that runs entirely in your browser. No server, ## Features -- **AES-256-GCM encryption** — All credentials encrypted with a key derived from your master password via PBKDF2 (100,000 iterations). The key exists only in memory. +- **AES-256-GCM encryption** — All credentials encrypted with a key derived from your master password via PBKDF2 (600,000 iterations). The key exists only in memory. - **Zero network calls** — Works from `file://` or `localhost`. No APIs, no analytics, no telemetry. - **Group management** — Organize entries into color-coded groups. Create, rename, delete. - **Full-text search** — Instant search across title, username, URL, and notes. -- **Password generator** — Configurable length (4–64), character types, custom exclusions, strength indicator. +- **Password generator** — One-click random password generation in the entry form (🎲 button). Uses Web Crypto API for cryptographically secure randomness. - **Copy to clipboard** — One-click copy with 15-second auto-clear. - **JSON import/export** — Export your entire vault as encrypted JSON. Import with merge or replace mode. - **Auto-lock** — Vault locks automatically on tab switch, visibility change, or 5-minute inactivity timer. @@ -40,7 +40,7 @@ The single-file output is handled by [`vite-plugin-singlefile`](https://www.npmj ``` Master Password ──PBKDF2──→ 256-bit Key ──AES-GCM──→ Encrypted Credential - (100k iters) + (600k iters) │ └── Salt stored in IndexedDB (not encrypted) ``` @@ -62,11 +62,11 @@ Master Password ──PBKDF2──→ 256-bit Key ──AES-GCM──→ Encrypt | Threat | Mitigation | |---|---| | Key persistence | Key stored only in `$state`, cleared on lock/close | -| Weak passwords | Strength indicator on generator | +| Weak passwords | 16-character default with mixed character types | | Clipboard leakage | Auto-clear after 15 seconds | | Tab left open | Auto-lock on visibility change (tab switch) | | Database tampering | Passwords encrypted at rest with AES-256-GCM | -| Brute force | PBKDF2 with 100,000 iterations slows offline attacks | +| Brute force | PBKDF2 with 600,000 iterations slows offline attacks | ### Known limitations diff --git a/dist/index.html b/dist/index.html index cc55f1c..a87c9b2 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5216,8 +5216,8 @@ async function encrypt(plaintext, key) { */ async function decrypt(encryptedJson, key) { const { iv, ciphertext } = JSON.parse(encryptedJson); - const ciphertextBuffer = base64ToUint8Array$1(ciphertext); - const ivBuffer = base64ToUint8Array$1(iv); + const ciphertextBuffer = base64ToUint8Array(ciphertext); + const ivBuffer = base64ToUint8Array(iv); const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuffer @@ -5262,7 +5262,7 @@ function uint8ArrayToBase64(buffer) { for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } -function base64ToUint8Array$1(base64) { +function base64ToUint8Array(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); @@ -5520,6 +5520,14 @@ async function updateEntry(entry) { await (await getDb()).put("entries", entry); } /** +* Delete an entry. +* @param {string} entryId +* @returns {Promise} +*/ +async function deleteEntry(entryId) { + await (await getDb()).delete("entries", entryId); +} +/** * Get a single entry by ID. * @param {string} entryId * @returns {Promise} @@ -5783,9 +5791,9 @@ function autofocus(node, condition = true) { } //#endregion //#region src/components/LockScreen.svelte -var root_1$8 = /* @__PURE__ */ from_html(``); +var root_1$7 = /* @__PURE__ */ from_html(``); var root_2$6 = /* @__PURE__ */ from_html(`
`); -var root$8 = /* @__PURE__ */ from_html(`
🔐

Password Vault

`); +var root$7 = /* @__PURE__ */ from_html(`
🔐

Password Vault

`); function LockScreen($$anchor, $$props) { push($$props, true); let masterPassword = /* @__PURE__ */ state(""); @@ -5847,14 +5855,14 @@ function LockScreen($$anchor, $$props) { set(masterPassword, ""); set(confirmPassword, ""); } - var div = root$8(); + var div = root$7(); var div_1 = child(div); var p = sibling(child(div_1), 4); var text = child(p, true); reset(p); var node = sibling(p, 2); var consequent = ($$anchor) => { - var div_2 = root_1$8(); + var div_2 = root_1$7(); var text_1 = child(div_2, true); reset(div_2); template_effect(() => set_text(text_1, get(error))); @@ -5968,7 +5976,7 @@ var root_4$5 = /* @__PURE__ */ from_html(``); var root_6$4 = /* @__PURE__ */ from_html(``); -var root$7 = /* @__PURE__ */ from_html(``); +var root$6 = /* @__PURE__ */ from_html(``); function Sidebar($$anchor, $$props) { push($$props, true); let groups = /* @__PURE__ */ state(proxy([])); @@ -5995,23 +6003,6 @@ function Sidebar($$anchor, $$props) { function canDrop(groupId) { return groupId !== search.activeGroupId && !isTrashGroup(groupId); } - const GROUP_COLORS = [ - "#6c63ff", - "#e5484d", - "#34d399", - "#fbbf24", - "#3b82f6", - "#ec4899", - "#8b5cf6", - "#14b8a6", - "#f97316", - "#06b6d4", - "#a855f7", - "#ef4444", - "#22c55e", - "#eab308", - "#6366f1" - ]; async function loadData() { await ensureTrashGroup(); set(groups, await getGroups(), true); @@ -6063,7 +6054,7 @@ function Sidebar($$anchor, $$props) { set(groupError, "Failed to delete group: " + e.message); } } - var div = root$7(); + var div = root$6(); var div_1 = sibling(child(div), 2); var input = child(div_1); remove_input_defaults(input); @@ -6236,7 +6227,7 @@ function Sidebar($$anchor, $$props) { delegate(["input", "click"]); //#endregion //#region src/components/EntryList.svelte -var root_1$7 = /* @__PURE__ */ from_html(`
Loading entries...
`); +var root_1$6 = /* @__PURE__ */ from_html(`
Loading entries...
`); var root_2$4 = /* @__PURE__ */ from_html(`
`); var root_4$4 = /* @__PURE__ */ from_html(``); var root_3$4 = /* @__PURE__ */ from_html(`

`); @@ -6246,7 +6237,7 @@ var root_9$1 = /* @__PURE__ */ from_html(``); var root_8$2 = /* @__PURE__ */ from_html(` `); var root_5$3 = /* @__PURE__ */ from_html(`
TitleUsernameURLNotes
`, 1); -var root$6 = /* @__PURE__ */ from_html(`
`); +var root$5 = /* @__PURE__ */ from_html(`
`); function EntryList($$anchor, $$props) { push($$props, true); let entries = /* @__PURE__ */ state(proxy([])); @@ -6285,10 +6276,10 @@ function EntryList($$anchor, $$props) { search.refreshTrigger; loadEntries(); }); - var div = root$6(); + var div = root$5(); var node = child(div); var consequent = ($$anchor) => { - append($$anchor, root_1$7()); + append($$anchor, root_1$6()); }; var consequent_1 = ($$anchor) => { var div_2 = root_2$4(); @@ -6443,7 +6434,7 @@ function EntryList($$anchor, $$props) { delegate(["click"]); //#endregion //#region src/components/EntryDetail.svelte -var root_1$6 = /* @__PURE__ */ from_html(`
`); +var root_1$5 = /* @__PURE__ */ from_html(`
`); var root_2$3 = /* @__PURE__ */ from_html(`
Loading...
`); var root_3$3 = /* @__PURE__ */ from_html(`
`); var root_4$3 = /* @__PURE__ */ from_html(`
Entry not found
`); @@ -6455,7 +6446,7 @@ var root_10 = /* @__PURE__ */ from_html(`
`); var root_12 = /* @__PURE__ */ from_html(``); var root_5$2 = /* @__PURE__ */ from_html(`

Password
`, 1); -var root$5 = /* @__PURE__ */ from_html(`
`); +var root$4 = /* @__PURE__ */ from_html(`
`); function EntryDetail($$anchor, $$props) { push($$props, true); let entry = /* @__PURE__ */ state(null); @@ -6523,8 +6514,7 @@ function EntryDetail($$anchor, $$props) { async function handlePermanentDelete() { set(deleting, true); try { - await moveToTrash($$props.entryId); - await emptyTrash(); + await deleteEntry($$props.entryId); $$props.onBack(); } catch (e) { set(error, "Failed to permanently delete: " + e.message); @@ -6532,10 +6522,10 @@ function EntryDetail($$anchor, $$props) { set(deleting, false); set(showPermanentDeleteConfirm, false); } - var div = root$5(); + var div = root$4(); var node = child(div); var consequent = ($$anchor) => { - var div_1 = root_1$6(); + var div_1 = root_1$5(); var text_1 = child(div_1, true); reset(div_1); template_effect(() => set_text(text_1, get(toast))); @@ -6747,7 +6737,6 @@ function EntryDetail($$anchor, $$props) { pop(); } delegate(["click"]); -delegate(["click"]); //#endregion //#region src/components/EntryForm.svelte var root_1$4 = /* @__PURE__ */ from_html(`
Loading...
`); diff --git a/src/components/EntryDetail.svelte b/src/components/EntryDetail.svelte index dd47fd2..2209a60 100644 --- a/src/components/EntryDetail.svelte +++ b/src/components/EntryDetail.svelte @@ -1,5 +1,5 @@ - -
- {#if toast} -
{toast}
- {/if} - -
-

🔑 Password Generator

- - -
- {generated} -
- {strength.label} - - -
-
- - -
-
- Length - {length} -
- -
- 4 - 16 - 32 - 64 -
-
- - -
- - - - -
- - -
- - -
-
-
- - diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index a99c80c..655d185 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -1,6 +1,6 @@