From 22614f9e8f78fa500a7d4336f32a01913fd80203 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 08:44:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(vault):=20phase=202=20=E2=80=94=20the?= =?UTF-8?q?=20Vault=20tab,=20history,=20trash=20and=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fifth tab beside sync | shield | pro | account. First phase users can see: create a vault, unlock it, keep logins, cards and identities, search them, copy a password, browse password history, use the trash, and import from Bitwarden, 1Password or Chrome. No autofill, no content scripts, and NO NEW PERMISSIONS. That is the point of shipping this before phase 3 — it is the half that earns its keep without asking anyone to grant access to every website they visit. Where the key lives, which is the whole MV3 problem. The service worker is killed after ~30s idle, so an unlocked key in a module variable is gone between one popup opening and the next and the vault appears to lock at random. It goes in chrome.storage.session: memory-only, never written to disk, cleared when the browser closes, and it survives worker restarts — exactly the lifetime an unlock should have. Access is pinned to TRUSTED_CONTEXTS so phase 3's content scripts can never read it. Auto-lock runs off chrome.alarms, not setTimeout, because a timer dies with the worker. Tested by re-importing the module, which is what a restart looks like. The popup never holds a key. It sends messages; the background owns the key and does every encrypt and decrypt. A bug in the UI can leak what is already on screen, not the vault. Signing out now clears session storage. clearUserData only removed local keys, so the vault would have stayed unlocked for whoever signed in next on the same profile. Generator: rejection sampling over crypto.getRandomValues, never Math.random and never a modulo, which quietly favours the front of the alphabet. Guarantees one character from each enabled group because sites reject a password that happens to contain no digit. The 256-word list gives a whole 8 bits per word, so the six-word default is exactly 48 — reported rather than implied. Import is a hand-written CSV reader because the failure mode of a sloppy one is silently importing half of somebody's passwords. It handles quoted commas, escaped quotes, embedded newlines, CRLF and a BOM — all of which appear in real exports, because notes fields contain everything. Source is detected from the header row, and 1Password is checked before Chrome since Chrome's columns are a subset of its own. Copied passwords clear from the clipboard after 30 seconds, and only if the clipboard still holds what we put there. Found and fixed while testing: pickIndex computed Math.floor(256/n)*n as its rejection ceiling, which is ZERO for n > 256 — so every draw was rejected and generating any password longer than 256 characters hung forever. It now draws as many bytes as the range needs. Regression test added. Tests: 84 new (32 in the package for the generator and importers, 42 on the extension session and UI helpers), 855 passing across both packages. The session tests assert the properties that matter: the key is in session storage and never local, the master password is stored nowhere, access is restricted to trusted contexts, an unlock survives a worker restart, and only ciphertext reaches the server. Still open: the migration from phase 1 is not applied to any database, so this cannot work against prod until it is. The web dashboard surface is not built — this is the extension only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D --- .../extension/__tests__/vault-session.test.js | 400 +++++++++++++++++ apps/extension/__tests__/vault-ui.test.js | 111 +++++ apps/extension/package.json | 1 + apps/extension/src/background/index.js | 80 ++++ .../extension/src/background/vault-session.js | 420 +++++++++++++++++ apps/extension/src/lib/api.js | 13 +- apps/extension/src/lib/vault-api.js | 148 ++++++ apps/extension/src/popup/Popup.jsx | 39 +- .../src/popup/components/VaultPanel.jsx | 422 ++++++++++++++++++ .../components/vault/VaultItemEditor.jsx | 339 ++++++++++++++ .../popup/components/vault/VaultUnlock.jsx | 297 ++++++++++++ packages/vault/__tests__/generator.test.js | 181 ++++++++ packages/vault/__tests__/import.test.js | 211 +++++++++ packages/vault/package.json | 4 +- packages/vault/src/generator.js | 201 +++++++++ packages/vault/src/import.js | 309 +++++++++++++ packages/vault/src/index.js | 21 + pnpm-lock.yaml | 3 + 18 files changed, 3197 insertions(+), 3 deletions(-) create mode 100644 apps/extension/__tests__/vault-session.test.js create mode 100644 apps/extension/__tests__/vault-ui.test.js create mode 100644 apps/extension/src/background/vault-session.js create mode 100644 apps/extension/src/lib/vault-api.js create mode 100644 apps/extension/src/popup/components/VaultPanel.jsx create mode 100644 apps/extension/src/popup/components/vault/VaultItemEditor.jsx create mode 100644 apps/extension/src/popup/components/vault/VaultUnlock.jsx create mode 100644 packages/vault/__tests__/generator.test.js create mode 100644 packages/vault/__tests__/import.test.js create mode 100644 packages/vault/src/generator.js create mode 100644 packages/vault/src/import.js diff --git a/apps/extension/__tests__/vault-session.test.js b/apps/extension/__tests__/vault-session.test.js new file mode 100644 index 0000000..022382a --- /dev/null +++ b/apps/extension/__tests__/vault-session.test.js @@ -0,0 +1,400 @@ +/** + * Tests for the extension's vault session. + * + * The properties that matter here are about *where the key lives*: in session + * storage so it survives a service-worker restart, out of local storage so it + * never touches disk, and gone on lock, auto-lock and sign-out. + * @module __tests__/vault-session.test + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockBrowser, localRef, sessionRef, alarmRef } = vi.hoisted(() => { + const localRef = { data: {} }; + const sessionRef = { data: {}, supported: true, accessLevel: null }; + const alarmRef = { alarms: {} }; + + const area = (ref) => ({ + get: vi.fn(async (key) => + typeof key === 'string' ? (key in ref.data ? { [key]: ref.data[key] } : {}) : { ...ref.data } + ), + set: vi.fn(async (obj) => { + Object.assign(ref.data, obj); + }), + remove: vi.fn(async (key) => { + for (const k of [].concat(key)) delete ref.data[k]; + }), + clear: vi.fn(async () => { + ref.data = {}; + }), + }); + + const mockBrowser = { + storage: { + local: area(localRef), + session: { + ...area(sessionRef), + setAccessLevel: vi.fn(async ({ accessLevel }) => { + sessionRef.accessLevel = accessLevel; + }), + }, + }, + alarms: { + create: vi.fn(async (name, opts) => { + alarmRef.alarms[name] = opts; + }), + clear: vi.fn(async (name) => { + delete alarmRef.alarms[name]; + }), + }, + }; + return { mockBrowser, localRef, sessionRef, alarmRef }; +}); + +vi.mock('webextension-polyfill', () => ({ default: mockBrowser })); + +// The API layer is mocked: these tests are about session handling, not HTTP. +const { serverRef } = vi.hoisted(() => ({ serverRef: { meta: null, items: [], nextId: 1 } })); +vi.mock('../src/lib/vault-api.js', () => ({ + fetchVaultMeta: vi.fn(async () => + serverRef.meta ? { exists: true, meta: serverRef.meta } : { exists: false, meta: null } + ), + saveVaultMeta: vi.fn(async (meta) => { + serverRef.meta = meta; + return true; + }), + fetchVaultItems: vi.fn(async ({ trash } = {}) => + serverRef.items.filter((row) => Boolean(row.deleted_at) === Boolean(trash)) + ), + createVaultItem: vi.fn(async (row) => { + const stored = { ...row, revision: 1, deleted_at: null }; + serverRef.items.push(stored); + return stored; + }), + updateVaultItem: vi.fn(async (id, row) => { + const existing = serverRef.items.find((r) => r.id === id); + if (!existing) return null; + if (existing.revision !== row.revision) return { conflict: true }; + Object.assign(existing, row, { revision: existing.revision + 1 }); + return existing; + }), + patchVaultItem: vi.fn(async (id, action) => { + const existing = serverRef.items.find((r) => r.id === id); + if (!existing) return false; + existing.deleted_at = action === 'trash' ? new Date().toISOString() : null; + return true; + }), + deleteVaultItem: vi.fn(async (id) => { + serverRef.items = serverRef.items.filter((r) => r.id !== id); + return true; + }), +})); + +async function loadModule() { + vi.resetModules(); + return import('../src/background/vault-session.js'); +} + +const PASSWORD = 'a-long-enough-master-password'; + +beforeEach(() => { + localRef.data = {}; + sessionRef.data = {}; + sessionRef.accessLevel = null; + alarmRef.alarms = {}; + serverRef.meta = null; + serverRef.items = []; +}); + +describe('setup and unlock', () => { + it('creates a vault, unlocks it, and returns a recovery key once', async () => { + const mod = await loadModule(); + const res = await mod.setupVault(PASSWORD); + + expect(res.success).toBe(true); + expect(res.unlocked).toBe(true); + expect(res.recoveryKey).toMatch(/^[0-9A-F-]+$/); + expect((await mod.getVaultStatus()).unlocked).toBe(true); + }, 30_000); + + it('refuses to create a second vault over an existing one', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + const again = await mod.setupVault('another-password-entirely'); + expect(again.success).toBe(false); + expect(again.error).toMatch(/already exists/); + }, 30_000); + + it('unlocks with the right password after a lock', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.lockVault(); + expect((await mod.getVaultStatus()).unlocked).toBe(false); + + expect((await mod.unlock(PASSWORD)).success).toBe(true); + expect((await mod.getVaultStatus()).unlocked).toBe(true); + }, 30_000); + + it('rejects the wrong password', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.lockVault(); + + const res = await mod.unlock('not the password'); + expect(res.success).toBe(false); + expect(res.error).toMatch(/Incorrect password/); + expect((await mod.getVaultStatus()).unlocked).toBe(false); + }, 30_000); + + it('reports that no vault exists before setup', async () => { + const mod = await loadModule(); + const status = await mod.getVaultStatus(); + expect(status.exists).toBe(false); + expect(status.unlocked).toBe(false); + }); +}); + +describe('where the key lives', () => { + it('keeps the unlocked key in session storage, never in local', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + // Session storage is memory-only and cleared when the browser closes. + expect(Object.keys(sessionRef.data)).toContain('vault-user-key'); + // Local storage is on disk — the key must never be written there. + expect(JSON.stringify(localRef.data)).not.toContain('vault-user-key'); + }, 30_000); + + it('never stores the master password anywhere', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + expect(JSON.stringify(localRef.data)).not.toContain(PASSWORD); + expect(JSON.stringify(sessionRef.data)).not.toContain(PASSWORD); + expect(JSON.stringify(serverRef.meta)).not.toContain(PASSWORD); + }, 30_000); + + it('restricts session storage to trusted contexts, so content scripts cannot read it', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + expect(sessionRef.accessLevel).toBe('TRUSTED_CONTEXTS'); + }, 30_000); + + it('survives a service-worker restart', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + // Re-importing the module is what a worker restart looks like: module state + // is gone, session storage is not. + const restarted = await loadModule(); + expect((await restarted.getVaultStatus()).unlocked).toBe(true); + }, 30_000); + + it('forgets the key on lock', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.lockVault(); + expect(sessionRef.data['vault-user-key']).toBeUndefined(); + }, 30_000); +}); + +describe('auto-lock', () => { + it('arms an alarm when the vault is unlocked', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + expect(alarmRef.alarms['marksyncr-vault-autolock']).toBeDefined(); + }, 30_000); + + it('uses an alarm and not a timer, so it survives the worker dying', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + expect(mockBrowser.alarms.create).toHaveBeenCalledWith( + 'marksyncr-vault-autolock', + expect.objectContaining({ delayInMinutes: expect.any(Number) }) + ); + }, 30_000); + + it('clears the alarm on lock', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.lockVault(); + expect(alarmRef.alarms['marksyncr-vault-autolock']).toBeUndefined(); + }, 30_000); + + it('arms no alarm when the timeout is "never"', async () => { + const mod = await loadModule(); + await mod.setVaultPrefs({ lockMinutes: 0 }); + await mod.setupVault(PASSWORD); + expect(alarmRef.alarms['marksyncr-vault-autolock']).toBeUndefined(); + }, 30_000); + + it('recognises its own alarm and no other', async () => { + const mod = await loadModule(); + expect(mod.isVaultLockAlarm('marksyncr-vault-autolock')).toBe(true); + expect(mod.isVaultLockAlarm('marksyncr-auto-sync')).toBe(false); + }); +}); + +describe('items', () => { + it('refuses to list while locked', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.lockVault(); + + const res = await mod.listItems(); + expect(res.success).toBe(false); + expect(res.locked).toBe(true); + }, 30_000); + + it('round-trips an item through encryption', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + const item = mod.buildItem('login', { + name: 'GitHub', + login: { username: 'anthony', password: 'hunter2' }, + }); + expect((await mod.saveItem(item)).success).toBe(true); + + const { items } = await mod.listItems(); + expect(items).toHaveLength(1); + expect(items[0].name).toBe('GitHub'); + expect(items[0].login.password).toBe('hunter2'); + }, 30_000); + + it('stores only ciphertext on the server', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.saveItem( + mod.buildItem('login', { name: 'My Bank', login: { username: 'me', password: 'hunter2' } }) + ); + + const wire = JSON.stringify(serverRef.items); + expect(wire).not.toContain('hunter2'); + expect(wire).not.toContain('My Bank'); + }, 30_000); + + it('reports a conflict rather than overwriting another device', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.saveItem(mod.buildItem('login', { name: 'GitHub' })); + + const { items } = await mod.listItems(); + const stale = { ...items[0], revision: 99 }; + + const res = await mod.saveItem(stale); + expect(res.success).toBe(false); + expect(res.conflict).toBe(true); + }, 30_000); + + it('moves to trash, restores, and deletes', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.saveItem(mod.buildItem('login', { name: 'GitHub' })); + + const { items } = await mod.listItems(); + const id = items[0].id; + + await mod.trashItem(id); + expect((await mod.listItems()).items).toHaveLength(0); + expect((await mod.listItems({ trash: true })).items).toHaveLength(1); + + await mod.restoreItem(id); + expect((await mod.listItems()).items).toHaveLength(1); + + await mod.destroyItem(id); + expect((await mod.listItems()).items).toHaveLength(0); + expect((await mod.listItems({ trash: true })).items).toHaveLength(0); + }, 30_000); + + it('imports many items and reports the count', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + const batch = [ + mod.buildItem('login', { name: 'One' }), + mod.buildItem('login', { name: 'Two' }), + ]; + const res = await mod.importItems(batch); + + expect(res.success).toBe(true); + expect(res.imported).toBe(2); + expect((await mod.listItems()).items).toHaveLength(2); + }, 30_000); +}); + +describe('buildItem', () => { + it('creates a new item with an id', async () => { + const mod = await loadModule(); + const item = mod.buildItem('login', { name: 'New' }); + expect(item.id).toBeTruthy(); + expect(item.name).toBe('New'); + }); + + it('keeps the replaced password in history when editing', async () => { + const mod = await loadModule(); + const original = mod.buildItem('login', { login: { password: 'old-one' } }); + const edited = mod.buildItem('login', { login: { password: 'new-one' } }, original); + + expect(edited.login.password).toBe('new-one'); + expect(edited.history[0].password).toBe('old-one'); + expect(edited.id).toBe(original.id); + }); + + it('does not add history when the password is unchanged', async () => { + const mod = await loadModule(); + const original = mod.buildItem('login', { name: 'x', login: { password: 'same' } }); + const edited = mod.buildItem('login', { name: 'renamed', login: { password: 'same' } }, original); + + expect(edited.history).toHaveLength(0); + expect(edited.name).toBe('renamed'); + }); +}); + +describe('changing the master password', () => { + it('works with the current password and keeps the items readable', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.saveItem(mod.buildItem('login', { name: 'GitHub', login: { password: 'p' } })); + + expect((await mod.changeMasterPassword(PASSWORD, 'a-new-master-password')).success).toBe(true); + + await mod.lockVault(); + expect((await mod.unlock('a-new-master-password')).success).toBe(true); + expect((await mod.listItems()).items[0].login.password).toBe('p'); + }, 60_000); + + it('refuses when the current password is wrong', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + + const res = await mod.changeMasterPassword('wrong', 'a-new-master-password'); + expect(res.success).toBe(false); + }, 30_000); +}); + +describe('recovery', () => { + it('gets back in with the recovery key and a new password', async () => { + const mod = await loadModule(); + const { recoveryKey } = await mod.setupVault(PASSWORD); + await mod.saveItem(mod.buildItem('login', { name: 'GitHub', login: { password: 'p' } })); + await mod.lockVault(); + + const res = await mod.recoverVault(recoveryKey, 'a-brand-new-password'); + expect(res.success).toBe(true); + expect((await mod.listItems()).items[0].login.password).toBe('p'); + + // The old password no longer works. + await mod.lockVault(); + expect((await mod.unlock(PASSWORD)).success).toBe(false); + }, 60_000); + + it('rejects a wrong recovery key', async () => { + const mod = await loadModule(); + await mod.setupVault(PASSWORD); + await mod.lockVault(); + + const res = await mod.recoverVault('AAAAA-BBBBB-CCCCC-DDDDD-EEEEE-FFFFF-11111-22222', 'new-password-here'); + expect(res.success).toBe(false); + }, 30_000); +}); diff --git a/apps/extension/__tests__/vault-ui.test.js b/apps/extension/__tests__/vault-ui.test.js new file mode 100644 index 0000000..736887c --- /dev/null +++ b/apps/extension/__tests__/vault-ui.test.js @@ -0,0 +1,111 @@ +/** + * Tests for the vault UI's pure helpers — search and master-password strength. + * @module __tests__/vault-ui.test + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('webextension-polyfill', () => ({ default: {} })); + +import { filterItems } from '../src/popup/components/VaultPanel.jsx'; +import { assessPassword } from '../src/popup/components/vault/VaultUnlock.jsx'; + +const items = [ + { + id: '1', + type: 'login', + name: 'GitHub', + notes: '', + login: { username: 'anthony', uris: [{ uri: 'https://github.com' }] }, + }, + { + id: '2', + type: 'card', + name: 'Travel Visa', + notes: 'expires soon', + card: { cardholderName: 'A Ettinger', brand: 'Visa' }, + }, + { + id: '3', + type: 'identity', + name: 'Home', + notes: '', + identity: { email: 'me@example.com', firstName: 'Ada', lastName: 'Lovelace' }, + }, +]; + +describe('filterItems', () => { + it('returns everything for an empty query', () => { + expect(filterItems(items, '')).toHaveLength(3); + expect(filterItems(items, ' ')).toHaveLength(3); + }); + + it('matches on name, case-insensitively', () => { + expect(filterItems(items, 'github')).toHaveLength(1); + expect(filterItems(items, 'GITHUB')[0].id).toBe('1'); + }); + + it('matches on a login username', () => { + expect(filterItems(items, 'anthony')[0].id).toBe('1'); + }); + + it('matches on the website, which is how people look a login up', () => { + expect(filterItems(items, 'github.com')[0].id).toBe('1'); + }); + + it('matches on an identity email and name', () => { + expect(filterItems(items, 'lovelace')[0].id).toBe('3'); + expect(filterItems(items, 'me@example')[0].id).toBe('3'); + }); + + it('matches on a card brand and cardholder', () => { + expect(filterItems(items, 'visa').map((i) => i.id)).toContain('2'); + expect(filterItems(items, 'ettinger')[0].id).toBe('2'); + }); + + it('matches on notes', () => { + expect(filterItems(items, 'expires')[0].id).toBe('2'); + }); + + it('returns nothing when nothing matches', () => { + expect(filterItems(items, 'zzzz')).toEqual([]); + }); + + it('does not blow up on items missing field groups', () => { + const sparse = [{ id: '9', type: 'note', name: 'Just a note' }]; + expect(filterItems(sparse, 'note')).toHaveLength(1); + expect(filterItems(sparse, 'nothing')).toHaveLength(0); + }); +}); + +describe('assessPassword', () => { + it('says nothing for an empty password', () => { + expect(assessPassword('')).toMatchObject({ score: 0, label: '' }); + }); + + it('calls out a password that is simply too short', () => { + const result = assessPassword('Ab1!xy'); + expect(result.label).toBe('Too short'); + expect(result.hint).toMatch(/at least 12/); + }); + + it('rates a long mixed password highly', () => { + const result = assessPassword('correct-horse-Battery-9-staple!'); + expect(result.score).toBeGreaterThanOrEqual(4); + expect(['Good', 'Strong']).toContain(result.label); + }); + + it('rates a long but monotonous password lower than a mixed one', () => { + const plain = assessPassword('aaaaaaaaaaaaaaaaaaaa'); + const mixed = assessPassword('aA1!aaaaaaaaaaaaaaaa'); + expect(plain.score).toBeLessThan(mixed.score); + }); + + it('is never negative or above five', () => { + for (const pw of ['', 'a', 'abcdefghijkl', 'aA1!'.repeat(20)]) { + const { score } = assessPassword(pw); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(5); + } + }); +}); diff --git a/apps/extension/package.json b/apps/extension/package.json index 29f3ae2..b7d1bd1 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -21,6 +21,7 @@ "@marksyncr/core": "workspace:*", "@marksyncr/sources": "workspace:*", "@marksyncr/types": "workspace:*", + "@marksyncr/vault": "workspace:*", "@supabase/supabase-js": "^2.47.10", "fuse.js": "^7.1.0", "react": "^19.0.0", diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js index 9b8a0b1..59848cf 100644 --- a/apps/extension/src/background/index.js +++ b/apps/extension/src/background/index.js @@ -9,6 +9,25 @@ */ import browser from 'webextension-polyfill'; +import { + initVaultSession, + isVaultLockAlarm, + getVaultStatus, + getVaultPrefs, + setVaultPrefs, + setupVault, + unlock as unlockVaultSession, + lockVault, + recoverVault, + changeMasterPassword, + listItems as listVaultItems, + saveItem as saveVaultItem, + buildItem as buildVaultItem, + trashItem as trashVaultItem, + restoreItem as restoreVaultItem, + destroyItem as destroyVaultItem, + importItems as importVaultItems, +} from './vault-session.js'; import { initAdblock, getAdblockStatus, @@ -3638,6 +3657,58 @@ browser.runtime.onMessage.addListener((message, sender) => { case 'SYNC_ADBLOCK_CLOUD': return syncAdblockFromCloud(); + // ----- Vault ----- + case 'VAULT_STATUS': + return getVaultStatus(); + + case 'VAULT_SETUP': + return setupVault(message.payload?.password); + + case 'VAULT_UNLOCK': + return unlockVaultSession(message.payload?.password); + + case 'VAULT_LOCK': + return lockVault(); + + case 'VAULT_RECOVER': + return recoverVault(message.payload?.recoveryKey, message.payload?.newPassword); + + case 'VAULT_CHANGE_PASSWORD': + return changeMasterPassword( + message.payload?.currentPassword, + message.payload?.newPassword + ); + + case 'VAULT_LIST': + return listVaultItems({ trash: Boolean(message.payload?.trash) }); + + case 'VAULT_SAVE_ITEM': { + const { type, fields, existing } = message.payload || {}; + const item = buildVaultItem(type, fields || {}, existing); + return saveVaultItem(item); + } + + case 'VAULT_TRASH_ITEM': + return trashVaultItem(message.payload?.id); + + case 'VAULT_RESTORE_ITEM': + return restoreVaultItem(message.payload?.id); + + case 'VAULT_DELETE_ITEM': + return destroyVaultItem(message.payload?.id); + + case 'VAULT_IMPORT': + return importVaultItems(message.payload?.items || []); + + case 'VAULT_GET_PREFS': + return getVaultPrefs().then((prefs) => ({ success: true, ...prefs })); + + case 'VAULT_SET_PREFS': + return setVaultPrefs({ lockMinutes: message.payload?.lockMinutes }).then((prefs) => ({ + success: true, + ...prefs, + })); + case 'GET_BLOCKED_REQUESTS': return getBlockedRequests(message.payload?.tabId); @@ -3659,6 +3730,9 @@ browser.runtime.onMessage.addListener((message, sender) => { // Must run synchronously at top level like every other listener below. initBlockedLog(); +// Vault session — hardens session storage so no content script can read the key. +initVaultSession(); + // Alarm handler - registered synchronously for Firefox MV3 compatibility browser.alarms.onAlarm.addListener(async (alarm) => { const browserInfo = detectBrowser(); @@ -3714,6 +3788,12 @@ browser.alarms.onAlarm.addListener(async (alarm) => { } } + if (isVaultLockAlarm(alarm.name)) { + console.log('[MarkSyncr] ⏰ Vault auto-lock triggered'); + await lockVault(); + return; + } + if (alarm.name === TOKEN_REFRESH_ALARM_NAME) { console.log('[MarkSyncr] ⏰ Token refresh alarm triggered'); diff --git a/apps/extension/src/background/vault-session.js b/apps/extension/src/background/vault-session.js new file mode 100644 index 0000000..6bfa3fe --- /dev/null +++ b/apps/extension/src/background/vault-session.js @@ -0,0 +1,420 @@ +/** + * MarkSyncr Vault — unlock session and item operations. + * + * All vault crypto happens here, in the background, and the popup never holds a + * key: it sends a message and gets back either plaintext items or an error. + * + * The unlocked user key lives in `chrome.storage.session`, which matters under + * MV3 for a specific reason. The service worker is killed after ~30 seconds + * idle, so a module-level variable holding the key would be lost between one + * popup opening and the next — the vault would appear to lock itself at random. + * Session storage is memory-only, never written to disk, cleared when the + * browser closes, and survives service-worker restarts within a session, which + * is exactly the lifetime a vault unlock should have. + * + * Auto-lock runs off `chrome.alarms` rather than setTimeout for the same + * reason: a timer dies with the worker, an alarm does not. + */ + +import browser from 'webextension-polyfill'; +import { + createVault, + unlockVault, + unlockWithRecoveryKey, + rewrapUserKey, + createItem, + recordPasswordChange, + encryptItem, + decryptItems, + toBase64, + fromBase64, + ITEM_TYPE, + ITEM_TYPE_NAME, +} from '@marksyncr/vault'; +import { + fetchVaultMeta, + saveVaultMeta, + fetchVaultItems, + createVaultItem, + updateVaultItem, + patchVaultItem, + deleteVaultItem, +} from '../lib/vault-api.js'; + +/** Where the unlocked key lives. Session storage only — never storage.local. */ +const SESSION_KEY = 'vault-user-key'; +/** Local, non-secret preferences. */ +const PREFS_KEY = 'vault-prefs'; +const LOCK_ALARM_NAME = 'marksyncr-vault-autolock'; + +/** Auto-lock choices offered in the UI, in minutes. 0 means "never". */ +export const LOCK_TIMEOUT_OPTIONS = [1, 5, 15, 30, 60, 0]; +const DEFAULT_LOCK_MINUTES = 15; + +/** + * Session storage is memory-only but still shared across extension contexts, so + * restrict it to trusted ones. Content scripts must never be able to read the + * vault key — this is the setting that guarantees it, and Phase 3's autofill + * will add content scripts. + */ +async function hardenSessionStorage() { + try { + await browser.storage.session?.setAccessLevel?.({ accessLevel: 'TRUSTED_CONTEXTS' }); + } catch { + /* not supported everywhere; TRUSTED_CONTEXTS is already the default */ + } +} + +/** @returns {Promise<{lockMinutes: number}>} */ +export async function getVaultPrefs() { + const stored = await browser.storage.local.get(PREFS_KEY); + const prefs = stored?.[PREFS_KEY] || {}; + return { + lockMinutes: Number.isFinite(prefs.lockMinutes) ? prefs.lockMinutes : DEFAULT_LOCK_MINUTES, + }; +} + +/** @param {{lockMinutes: number}} prefs */ +export async function setVaultPrefs(prefs) { + await browser.storage.local.set({ [PREFS_KEY]: prefs }); + await scheduleAutoLock(); + return prefs; +} + +/** The unlocked user key, or null when locked. */ +async function getSessionKey() { + if (!browser.storage.session) return null; + const stored = await browser.storage.session.get(SESSION_KEY); + const b64 = stored?.[SESSION_KEY]; + return b64 ? fromBase64(b64) : null; +} + +/** @param {Uint8Array} userKey */ +async function setSessionKey(userKey) { + await hardenSessionStorage(); + await browser.storage.session.set({ [SESSION_KEY]: toBase64(userKey) }); + await scheduleAutoLock(); +} + +/** Re-arm the auto-lock alarm from now. */ +async function scheduleAutoLock() { + const { lockMinutes } = await getVaultPrefs(); + try { + await browser.alarms.clear(LOCK_ALARM_NAME); + if (lockMinutes > 0) { + await browser.alarms.create(LOCK_ALARM_NAME, { delayInMinutes: lockMinutes }); + } + } catch (err) { + console.warn('[MarkSyncr] Could not schedule vault auto-lock:', err?.message); + } +} + +/** True when the alarm belongs to this module. */ +export function isVaultLockAlarm(alarmName) { + return alarmName === LOCK_ALARM_NAME; +} + +/** Forget the key. Called by the alarm, by sign-out, and by the Lock button. */ +export async function lockVault() { + try { + await browser.storage.session?.remove(SESSION_KEY); + } catch { + /* already gone */ + } + try { + await browser.alarms.clear(LOCK_ALARM_NAME); + } catch { + /* ignore */ + } + return { success: true, unlocked: false }; +} + +/** + * Whether a vault exists for this account and whether it is currently open. + * @returns {Promise} + */ +export async function getVaultStatus() { + const [key, prefs] = await Promise.all([getSessionKey(), getVaultPrefs()]); + + // A locked vault still needs to know whether one exists, to choose between + // "unlock" and "set up" — but that is the only reason to call the API here. + let exists = null; + if (!key) { + const meta = await fetchVaultMeta().catch(() => null); + exists = meta ? Boolean(meta.exists) : null; + } else { + exists = true; + } + + return { + success: true, + exists, + unlocked: Boolean(key), + lockMinutes: prefs.lockMinutes, + sessionSupported: Boolean(browser.storage.session), + }; +} + +/** + * Create a vault and unlock it. + * @param {string} password + * @returns {Promise} includes the recovery key, shown once + */ +export async function setupVault(password) { + const existing = await fetchVaultMeta(); + if (existing?.exists) { + return { success: false, error: 'A vault already exists for this account' }; + } + + const { meta, userKey, recoveryKey } = await createVault(password); + const saved = await saveVaultMeta(meta); + if (!saved) { + return { success: false, error: 'Could not save the vault. Check your connection.' }; + } + + await setSessionKey(userKey); + return { success: true, unlocked: true, recoveryKey }; +} + +/** + * Unlock with the master password. + * @param {string} password + */ +export async function unlock(password) { + const res = await fetchVaultMeta(); + if (!res?.exists) { + return { success: false, error: 'No vault has been set up yet' }; + } + + try { + const { userKey } = await unlockVault(password, res.meta); + await setSessionKey(userKey); + return { success: true, unlocked: true }; + } catch (err) { + // unlockVault throws a deliberately uninformative "Incorrect password" for + // both a wrong password and a tampered blob. + return { success: false, error: err.message || 'Incorrect password' }; + } +} + +/** + * Unlock with the recovery key, then set a new master password. + * @param {string} recoveryKey + * @param {string} newPassword + */ +export async function recoverVault(recoveryKey, newPassword) { + const res = await fetchVaultMeta(); + if (!res?.exists) { + return { success: false, error: 'No vault has been set up yet' }; + } + + try { + const { userKey } = await unlockWithRecoveryKey(recoveryKey, res.meta); + const { meta } = await rewrapUserKey(userKey, newPassword, res.meta); + const saved = await saveVaultMeta(meta); + if (!saved) return { success: false, error: 'Could not save the new password' }; + + await setSessionKey(userKey); + return { success: true, unlocked: true }; + } catch (err) { + return { success: false, error: err.message || 'Incorrect recovery key' }; + } +} + +/** + * Change the master password. Requires the vault to be unlocked, so the user + * key is already available and no item has to be re-encrypted. + * @param {string} currentPassword + * @param {string} newPassword + */ +export async function changeMasterPassword(currentPassword, newPassword) { + const res = await fetchVaultMeta(); + if (!res?.exists) return { success: false, error: 'No vault has been set up yet' }; + + try { + // Verify the current password by unlocking with it, rather than trusting + // the session — otherwise anyone at an unlocked browser could change it. + const { userKey } = await unlockVault(currentPassword, res.meta); + const { meta } = await rewrapUserKey(userKey, newPassword, res.meta); + const saved = await saveVaultMeta(meta); + if (!saved) return { success: false, error: 'Could not save the new password' }; + + await setSessionKey(userKey); + return { success: true }; + } catch (err) { + return { success: false, error: err.message || 'Incorrect password' }; + } +} + +/** Require an unlocked vault, or explain why the caller cannot proceed. */ +async function requireKey() { + const key = await getSessionKey(); + if (!key) throw new Error('LOCKED'); + return key; +} + +/** + * List decrypted items. + * @param {{ trash?: boolean }} [options] + */ +export async function listItems({ trash = false } = {}) { + try { + const userKey = await requireKey(); + const rows = await fetchVaultItems({ trash }); + const { items, failed } = await decryptItems(userKey, rows); + + // Sort by name for a stable list; the API orders by updated_at, which + // reshuffles the list every time the user edits something. + items.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + + // Carry the server's revision and trash state alongside each item — the + // client needs the revision to write, and it is not inside the ciphertext. + const byId = new Map(rows.map((row) => [row.id, row])); + const withMeta = items.map((item) => ({ + ...item, + revision: byId.get(item.id)?.revision ?? 1, + deletedAt: byId.get(item.id)?.deleted_at ?? null, + })); + + return { success: true, items: withMeta, failed, unlocked: true }; + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message || 'Could not load the vault' }; + } +} + +/** + * Create or update an item. + * + * `item.revision` decides which: absent means create. A stale revision comes + * back from the API as a conflict rather than overwriting another device. + * @param {Object} item a plaintext item + */ +export async function saveItem(item) { + try { + const userKey = await requireKey(); + const row = await encryptItem(userKey, item); + + if (item.revision) { + const result = await updateVaultItem(item.id, { ...row, revision: item.revision }); + if (result?.conflict) { + return { + success: false, + conflict: true, + error: 'This item was changed on another device. Reload to see the current version.', + }; + } + if (!result) return { success: false, error: 'Could not save the item' }; + return { success: true, item: { ...item, revision: result.revision } }; + } + + const created = await createVaultItem(row); + if (!created) return { success: false, error: 'Could not save the item' }; + return { success: true, item: { ...item, revision: created.revision } }; + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message || 'Could not save the item' }; + } +} + +/** + * Build a new item, applying password history when one is being replaced. + * @param {string} type + * @param {Object} fields + * @param {Object} [existing] the item being edited, if any + */ +export function buildItem(type, fields, existing) { + if (!existing) return createItem(type, fields); + + const merged = { + ...existing, + ...fields, + [type]: { ...existing[type], ...(fields[type] || {}) }, + updatedAt: new Date().toISOString(), + }; + + // A changed login password goes through recordPasswordChange so the old one + // is kept in history rather than lost. + const nextPassword = fields[type]?.password; + if (type === 'login' && typeof nextPassword === 'string') { + return recordPasswordChange({ ...merged, login: { ...existing.login } }, nextPassword); + } + return merged; +} + +/** Move an item to the trash. */ +export async function trashItem(id) { + try { + await requireKey(); + const ok = await patchVaultItem(id, 'trash'); + return ok ? { success: true } : { success: false, error: 'Could not move the item to trash' }; + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message }; + } +} + +/** Restore an item from the trash. */ +export async function restoreItem(id) { + try { + await requireKey(); + const ok = await patchVaultItem(id, 'restore'); + return ok ? { success: true } : { success: false, error: 'Could not restore the item' }; + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message }; + } +} + +/** Delete an item permanently. */ +export async function destroyItem(id) { + try { + await requireKey(); + const ok = await deleteVaultItem(id); + return ok ? { success: true } : { success: false, error: 'Could not delete the item' }; + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message }; + } +} + +/** + * Import parsed items, encrypting each one. + * + * Reports per-item failures instead of stopping, so one bad row out of five + * hundred does not abandon the other four hundred and ninety-nine. + * @param {Object[]} items plaintext items from parseImport + */ +export async function importItems(items) { + try { + const userKey = await requireKey(); + let imported = 0; + const failures = []; + + for (const item of items) { + try { + const row = await encryptItem(userKey, item); + const created = await createVaultItem(row); + if (created) imported += 1; + else failures.push({ name: item.name, reason: 'Server rejected the item' }); + } catch (err) { + failures.push({ name: item.name, reason: err.message }); + } + } + + return { success: true, imported, failures }; + } catch (err) { + if (err.message === 'LOCKED') return { success: false, locked: true, error: 'Vault is locked' }; + return { success: false, error: err.message }; + } +} + +/** + * Register the listeners the vault needs. Called synchronously at top level, so + * the alarm handler exists before any alarm can fire. + */ +export function initVaultSession() { + hardenSessionStorage().catch(() => {}); +} + +export { ITEM_TYPE, ITEM_TYPE_NAME }; diff --git a/apps/extension/src/lib/api.js b/apps/extension/src/lib/api.js index c6ea6ec..7517952 100644 --- a/apps/extension/src/lib/api.js +++ b/apps/extension/src/lib/api.js @@ -70,6 +70,17 @@ async function clearUserData() { if (!browserAPI) return; await browserAPI.storage.local.remove(['user', 'isLoggedIn', 'session']); + + // Signing out must lock the vault. The unlocked key lives in session storage, + // which clearing local storage does not touch — leaving it behind would keep + // the vault open for whoever signs in next on this profile. Session storage + // holds nothing but that key, so clearing it wholesale is both safe and + // future-proof against another secret being put there. + try { + await browserAPI.storage.session?.clear(); + } catch { + /* session storage unavailable — nothing to clear */ + } } /** @@ -89,7 +100,7 @@ async function storeUserData(user) { * Make an authenticated API request * Uses Bearer token in Authorization header */ -async function apiRequest(endpoint, options = {}) { +export async function apiRequest(endpoint, options = {}) { const token = await getAccessToken(); const headers = { diff --git a/apps/extension/src/lib/vault-api.js b/apps/extension/src/lib/vault-api.js new file mode 100644 index 0000000..185ab18 --- /dev/null +++ b/apps/extension/src/lib/vault-api.js @@ -0,0 +1,148 @@ +/** + * Vault API client. + * + * Every payload crossing this boundary is already ciphertext — this module + * never sees a plaintext item, a password, or a key. It reuses `apiRequest` + * from api.js so vault calls get the same Bearer token and the same automatic + * refresh as the rest of the extension. + */ + +import { apiRequest } from './api.js'; + +/** + * Fetch the caller's vault key material. + * @returns {Promise<{exists: boolean, meta: Object|null}|null>} null on failure + */ +export async function fetchVaultMeta() { + try { + const response = await apiRequest('/api/vault/meta', { method: 'GET' }); + if (!response.ok) return null; + return await response.json(); + } catch (err) { + console.error('[MarkSyncr] Vault meta fetch failed:', err?.message); + return null; + } +} + +/** + * Create a vault, or re-wrap it after a password change. + * @param {Object} meta + * @returns {Promise} + */ +export async function saveVaultMeta(meta) { + try { + const response = await apiRequest('/api/vault/meta', { + method: 'POST', + body: JSON.stringify(meta), + }); + return response.ok; + } catch (err) { + console.error('[MarkSyncr] Vault meta save failed:', err?.message); + return false; + } +} + +/** + * List encrypted item rows. + * @param {{ trash?: boolean, since?: string }} [options] + * @returns {Promise} + */ +export async function fetchVaultItems({ trash = false, since } = {}) { + const params = new URLSearchParams(); + if (trash) params.set('trash', '1'); + if (since) params.set('since', since); + const query = params.toString(); + + try { + const response = await apiRequest(`/api/vault/items${query ? `?${query}` : ''}`, { + method: 'GET', + }); + if (!response.ok) return []; + const data = await response.json(); + return data.items || []; + } catch (err) { + console.error('[MarkSyncr] Vault items fetch failed:', err?.message); + return []; + } +} + +/** + * Create an item. + * @param {{id: string, type: number, ciphertext: string, iv: string}} row + * @returns {Promise} the created row, or null + */ +export async function createVaultItem(row) { + try { + const response = await apiRequest('/api/vault/items', { + method: 'POST', + body: JSON.stringify(row), + }); + if (!response.ok) return null; + const data = await response.json(); + return data.item || null; + } catch (err) { + console.error('[MarkSyncr] Vault item create failed:', err?.message); + return null; + } +} + +/** + * Replace an item's ciphertext. + * + * A 409 means another device wrote first. That is surfaced as `{conflict: true}` + * rather than an error, because the caller has to do something different about + * it — reload and merge, not retry. + * + * @param {string} id + * @param {{type: number, ciphertext: string, iv: string, revision: number}} row + * @returns {Promise} + */ +export async function updateVaultItem(id, row) { + try { + const response = await apiRequest(`/api/vault/items/${id}`, { + method: 'PUT', + body: JSON.stringify(row), + }); + if (response.status === 409) return { conflict: true }; + if (!response.ok) return null; + const data = await response.json(); + return data.item || null; + } catch (err) { + console.error('[MarkSyncr] Vault item update failed:', err?.message); + return null; + } +} + +/** + * Move an item to the trash, or restore it. + * @param {string} id + * @param {'trash'|'restore'} action + * @returns {Promise} + */ +export async function patchVaultItem(id, action) { + try { + const response = await apiRequest(`/api/vault/items/${id}`, { + method: 'PATCH', + body: JSON.stringify({ action }), + }); + return response.ok; + } catch (err) { + console.error(`[MarkSyncr] Vault item ${action} failed:`, err?.message); + return false; + } +} + +/** + * Delete an item permanently. + * @param {string} id + * @returns {Promise} + */ +export async function deleteVaultItem(id) { + try { + const response = await apiRequest(`/api/vault/items/${id}`, { method: 'DELETE' }); + return response.ok; + } catch (err) { + console.error('[MarkSyncr] Vault item delete failed:', err?.message); + return false; + } +} diff --git a/apps/extension/src/popup/Popup.jsx b/apps/extension/src/popup/Popup.jsx index b56cf9e..e688ce1 100644 --- a/apps/extension/src/popup/Popup.jsx +++ b/apps/extension/src/popup/Popup.jsx @@ -3,6 +3,7 @@ import { useStore } from '../store/index.js'; import { ProFeaturesPanel } from './components/ProFeaturesPanel.jsx'; import { LoginPanel } from './components/LoginPanel.jsx'; import { AdblockPanel } from './components/AdblockPanel.jsx'; +import { VaultPanel } from './components/VaultPanel.jsx'; // Confirmation Dialog Component using native element function ConfirmDialog({ @@ -496,7 +497,7 @@ export function Popup() { } = useStore(); const [isInitialized, setIsInitialized] = useState(false); - const [activeTab, setActiveTab] = useState('sync'); // 'sync' | 'shield' | 'pro' | 'account' + const [activeTab, setActiveTab] = useState('sync'); // 'sync' | 'shield' | 'vault' | 'pro' | 'account' const [exportMessage, setExportMessage] = useState(null); const [forceActionMessage, setForceActionMessage] = useState(null); const fileInputRef = useRef(null); @@ -826,6 +827,26 @@ ${content} Shield + + + ))} + {activeTab === 'pro' && ( { + const haystack = [ + item.name, + item.notes, + item.login?.username, + item.login?.uris?.[0]?.uri, + item.identity?.email, + item.identity?.firstName, + item.identity?.lastName, + item.card?.cardholderName, + item.card?.brand, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(q); + }); +} + +/** Copy a secret, then clear it from the clipboard after a delay. */ +async function copySecret(value, onCopied) { + try { + await navigator.clipboard.writeText(value); + onCopied(); + setTimeout(() => { + // Best effort: only clear if the clipboard still holds what we put there, + // so we never wipe something the user copied in the meantime. + navigator.clipboard + .readText() + .then((current) => { + if (current === value) navigator.clipboard.writeText(''); + }) + .catch(() => { + /* read permission denied — leave the clipboard alone */ + }); + }, CLIPBOARD_CLEAR_MS); + } catch { + onCopied('Could not copy'); + } +} + +function ItemRow({ item, trash, onOpen, onCopy, onTrash, onRestore, onDelete }) { + const meta = TYPE_META[item.type] || TYPE_META.note; + const subtitle = + item.login?.username || + item.identity?.email || + (item.card?.number ? `•••• ${item.card.number.slice(-4)}` : '') || + ''; + + return ( +
+ + + {!trash && item.type === 'login' && item.login?.password && ( + + )} + + {trash ? ( + <> + + + + ) : ( + + )} +
+ ); +} + +export function VaultPanel() { + const [status, setStatus] = useState(null); + const [items, setItems] = useState([]); + const [failed, setFailed] = useState([]); + const [query, setQuery] = useState(''); + const [showTrash, setShowTrash] = useState(false); + const [editing, setEditing] = useState(null); // { item } | { type } + const [toast, setToast] = useState(''); + const [loading, setLoading] = useState(true); + + const refreshStatus = useCallback(async () => { + const res = await sendMessage({ type: 'VAULT_STATUS' }); + if (res?.success) setStatus(res); + return res; + }, []); + + const loadItems = useCallback(async (trash) => { + const res = await sendMessage({ type: 'VAULT_LIST', payload: { trash } }); + if (res?.success) { + setItems(res.items || []); + setFailed(res.failed || []); + } else if (res?.locked) { + setStatus((s) => (s ? { ...s, unlocked: false } : s)); + } + }, []); + + useEffect(() => { + (async () => { + const res = await refreshStatus(); + if (res?.unlocked) await loadItems(false); + setLoading(false); + })(); + }, [refreshStatus, loadItems]); + + const notify = (message) => { + setToast(message); + setTimeout(() => setToast(''), 2500); + }; + + const onUnlocked = async () => { + await refreshStatus(); + await loadItems(showTrash); + }; + + const onSave = async (type, fields, existing) => { + const res = await sendMessage({ + type: 'VAULT_SAVE_ITEM', + payload: { type, fields, existing }, + }); + if (res?.success) { + setEditing(null); + await loadItems(showTrash); + notify('Saved'); + } + return res; + }; + + const onTrash = async (item) => { + const res = await sendMessage({ type: 'VAULT_TRASH_ITEM', payload: { id: item.id } }); + if (res?.success) { + await loadItems(showTrash); + notify('Moved to trash'); + } else notify(res?.error || 'Could not move to trash'); + }; + + const onRestore = async (item) => { + const res = await sendMessage({ type: 'VAULT_RESTORE_ITEM', payload: { id: item.id } }); + if (res?.success) { + await loadItems(showTrash); + notify('Restored'); + } else notify(res?.error || 'Could not restore'); + }; + + const onDelete = async (item) => { + const res = await sendMessage({ type: 'VAULT_DELETE_ITEM', payload: { id: item.id } }); + if (res?.success) { + await loadItems(showTrash); + notify('Deleted permanently'); + } else notify(res?.error || 'Could not delete'); + }; + + const onLock = async () => { + await sendMessage({ type: 'VAULT_LOCK' }); + setItems([]); + await refreshStatus(); + }; + + const onImport = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + event.target.value = ''; + + const text = await file.text(); + const { source, items: parsed, skipped } = parseImport(text); + + if (!parsed.length) { + notify(skipped[0]?.reason || 'Nothing to import'); + return; + } + + const res = await sendMessage({ type: 'VAULT_IMPORT', payload: { items: parsed } }); + if (res?.success) { + await loadItems(showTrash); + notify(`Imported ${res.imported} from ${source}`); + } else { + notify(res?.error || 'Import failed'); + } + }; + + const visible = useMemo(() => filterItems(items, query), [items, query]); + + if (loading) { + return ( +
+ Loading vault… +
+ ); + } + + if (status?.sessionSupported === false) { + return ( +
+ This browser cannot hold an unlocked vault safely in memory, so the vault is unavailable + here. +
+ ); + } + + if (!status?.unlocked) { + return ( + sendMessage({ type: 'VAULT_SETUP', payload: { password } })} + onUnlock={(password) => sendMessage({ type: 'VAULT_UNLOCK', payload: { password } })} + onRecover={(recoveryKey, newPassword) => + sendMessage({ type: 'VAULT_RECOVER', payload: { recoveryKey, newPassword } }) + } + onUnlocked={onUnlocked} + /> + ); + } + + if (editing) { + return ( + setEditing(null)} + /> + ); + } + + return ( +
+
+ setQuery(e.target.value)} + placeholder={showTrash ? 'Search trash…' : 'Search vault…'} + className="min-w-0 flex-1 rounded-lg border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> + +
+ + {!showTrash && ( +
+ {['login', 'card', 'identity'].map((type) => ( + + ))} +
+ )} + + {failed.length > 0 && ( +

+ {failed.length} {failed.length === 1 ? 'item' : 'items'} could not be decrypted and are + hidden. +

+ )} + +
+ {visible.length === 0 ? ( +

+ {query + ? 'Nothing matches that search.' + : showTrash + ? 'The trash is empty.' + : 'Your vault is empty. Add a login, or import from another manager.'} +

+ ) : ( + visible.map((item) => ( + setEditing({ item: it })} + onCopy={(value) => copySecret(value, (err) => notify(err || 'Password copied'))} + onTrash={onTrash} + onRestore={onRestore} + onDelete={onDelete} + /> + )) + )} +
+ +
+ + + {!showTrash && ( + + )} +
+ + {toast && ( +

+ {toast} +

+ )} + +

+ Everything here is encrypted on this device before it is saved. MarkSyncr cannot read your + vault, and cannot reset your vault password. Copied passwords clear from the clipboard after + 30 seconds. +

+
+ ); +} diff --git a/apps/extension/src/popup/components/vault/VaultItemEditor.jsx b/apps/extension/src/popup/components/vault/VaultItemEditor.jsx new file mode 100644 index 0000000..59826a0 --- /dev/null +++ b/apps/extension/src/popup/components/vault/VaultItemEditor.jsx @@ -0,0 +1,339 @@ +import React, { useState } from 'react'; +import { generatePassword, generatePassphrase, passwordEntropyBits } from '@marksyncr/vault'; + +/** + * Create and edit vault items. + * + * One editor for all three types — logins, cards and identities differ only in + * which field group they render, because they are one record shape underneath. + */ + +const TYPE_LABELS = { login: 'Login', card: 'Card', identity: 'Identity', note: 'Note' }; + +function Field({ label, value, onChange, type = 'text', mono = false, autoFocus = false }) { + return ( + + ); +} + +/** The password field, with reveal and a generator. */ +function PasswordField({ value, onChange }) { + const [revealed, setRevealed] = useState(false); + const [showGenerator, setShowGenerator] = useState(false); + const [length, setLength] = useState(20); + const [usePassphrase, setUsePassphrase] = useState(false); + const [symbols, setSymbols] = useState(true); + + const generate = () => { + try { + onChange( + usePassphrase + ? generatePassphrase({ words: 5, capitalize: true, includeNumber: true }) + : generatePassword({ length, symbols }) + ); + setRevealed(true); + } catch { + /* the options always satisfy the generator's minimums */ + } + }; + + return ( +
+
+ Password + +
+ +
+ onChange(e.target.value)} + autoComplete="new-password" + spellCheck={false} + className="min-w-0 flex-1 rounded-lg border border-slate-300 px-2.5 py-1.5 font-mono text-xs focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> + +
+ + {showGenerator && ( +
+
+ + +
+ + {!usePassphrase && ( + <> + + +

+ About {passwordEntropyBits({ length, symbols })} bits of entropy +

+ + )} + + +
+ )} +
+ ); +} + +/** Previous passwords, newest first. */ +function PasswordHistory({ history }) { + const [open, setOpen] = useState(false); + const [revealed, setRevealed] = useState(null); + + if (!history?.length) return null; + + return ( +
+ + + {open && ( +
    + {history.map((entry, i) => ( +
  • + + {revealed === i ? entry.password : '••••••••••••'} + + + {new Date(entry.changedAt).toLocaleDateString()} + + +
  • + ))} +
+ )} +
+ ); +} + +/** + * @param {Object} props + * @param {Object|null} props.item the item being edited, or null to create + * @param {string} props.type item type when creating + * @param {(type: string, fields: Object, existing: Object|null) => Promise} props.onSave + * @param {() => void} props.onCancel + */ +export function VaultItemEditor({ item, type: initialType, onSave, onCancel }) { + const type = item?.type || initialType || 'login'; + const [name, setName] = useState(item?.name || ''); + const [notes, setNotes] = useState(item?.notes || ''); + const [group, setGroup] = useState(() => ({ ...(item?.[type] || {}) })); + const [uri, setUri] = useState(item?.login?.uris?.[0]?.uri || ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const setField = (key) => (value) => setGroup((g) => ({ ...g, [key]: value })); + + const submit = async (event) => { + event.preventDefault(); + setError(''); + setBusy(true); + + const fields = { name, notes, [type]: { ...group } }; + if (type === 'login') { + fields.login.uris = uri ? [{ uri, match: 'domain' }] : []; + } + + const res = await onSave(type, fields, item); + setBusy(false); + if (!res?.success) setError(res?.error || 'Could not save'); + }; + + return ( +
+
+

+ {item ? 'Edit' : 'New'} {TYPE_LABELS[type].toLowerCase()} +

+ +
+ + + + {type === 'login' && ( + <> + + + + + + + )} + + {type === 'card' && ( + <> + + +
+ + + +
+ + + )} + + {type === 'identity' && ( + <> +
+ + +
+ + + + +
+ + +
+
+ + +
+ + )} + +