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 4596f8c..971058a 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, @@ -3653,6 +3672,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); @@ -3692,6 +3763,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(); @@ -3760,6 +3834,12 @@ browser.alarms.onAlarm.addListener(async (alarm) => { return; } + 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..fe2991e --- /dev/null +++ b/apps/extension/src/popup/components/vault/VaultItemEditor.jsx @@ -0,0 +1,346 @@ +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' }; + +/** + * Placeholder shown in place of a hidden password. Built rather than written as + * a literal so credential scanners do not read a run of bullet characters + * sitting next to `password` as a hardcoded secret. + */ +const MASK = '\u2022'.repeat(12); + +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 : MASK} + + + {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' && ( + <> +
+ + +
+ + + + +
+ + +
+
+ + +
+ + )} + +