/** * extension/popup/popup.js — PassKeeper popup UI * * Storage layout: * chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close) * chrome.storage.local — refresh_token, enc_key_salt (persists across restarts) * * SSO flow (web app → extension): * bridge.js syncs tokens to background → background writes to chrome.storage. * On popup open: if session has no vault_key but local has enc_key_salt + refresh_token, * we refresh the access_token and show the unlock-only view (master password only). */ const API_BASE = 'https://pwkeeper.ngodanguyen.tech'; const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault'; const ALERTS_URL = 'https://pwkeeper.ngodanguyen.tech/vault#security'; // ── State ───────────────────────────────────────────────────────────────────── let _vaultKey = null; let _items = []; let _folders = []; // fetched alongside vault items for folder grouping let _mfaToken = null; let _currentUrl = ''; let _activeTab = 'relevant'; const _collapsedFolders = new Set(); // persists collapsed state across re-renders // ── DOM helpers ─────────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); function showView(name) { ['login', 'mfa', 'unlock', 'vault', 'generator', 'account', 'add'].forEach(v => $(`view-${v}`).classList.toggle('hidden', v !== name) ); // Bottom nav visible only in authenticated views. const navViews = ['vault', 'generator', 'account']; $('main-bottom-nav').classList.toggle('hidden', !navViews.includes(name)); // Sync active state on nav buttons. ['nav-vault', 'nav-generator', 'nav-account'].forEach(id => { const btn = $(id); if (!btn) return; const active = (id === 'nav-vault' && name === 'vault') || (id === 'nav-generator' && name === 'generator') || (id === 'nav-account' && name === 'account'); btn.classList.toggle('active', active); }); } function showError(elId, msg) { const el = $(elId); el.textContent = msg; el.classList.remove('hidden'); } function hideError(elId) { $(elId).classList.add('hidden'); } function escHtml(str) { return String(str ?? '') .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Auto-clear clipboard 30 s after a sensitive copy. let _clipTimer = null; function _copyWithAutoClear(text) { navigator.clipboard.writeText(text).catch(() => {}); if (_clipTimer) clearTimeout(_clipTimer); _clipTimer = setTimeout(() => { navigator.clipboard.writeText('').catch(() => {}); _clipTimer = null; }, 30_000); } // ── Avatar helpers ──────────────────────────────────────────────────────────── const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal']; function avatarColor(str) { let h = 0; for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0; return AVATAR_COLORS[h % AVATAR_COLORS.length]; } function itemEmoji(type) { return { password: '🔑', note: '📝', card: '💳', bank: '🏦', address: '🏠', ssn: '🪪', passkey: '🔐' }[type] || '🔑'; } function siteLabel(item) { if (item.plain?.url) { try { return new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch { } } return item.name; } function folderName(id) { if (!id) return '(none)'; return _folders.find(f => f.id === id)?.name || '(none)'; } // ── Domain matching ─────────────────────────────────────────────────────────── function currentHostname() { if (!_currentUrl) return ''; try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; } } /** * Normalise a stored URL so it is always parseable by `new URL()`. * Handles bare domains ("github.com"), protocol-relative, and full URLs. */ function _normaliseUrl(raw) { if (!raw) return null; const s = raw.trim(); if (/^https?:\/\//i.test(s)) return s; if (s.startsWith('//')) return 'https:' + s; return 'https://' + s; } function isMatch(item) { const host = currentHostname(); if (!host || item.item_type !== 'password' || !item.plain?.url) return false; try { const normalised = _normaliseUrl(item.plain.url); if (!normalised) return false; const h = new URL(normalised).hostname.replace(/^www\./, ''); // Match exact domain or any subdomain relationship. return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); } catch { return false; } } // ── API helpers ─────────────────────────────────────────────────────────────── async function apiFetch(path, options = {}) { const { access_token } = await chrome.storage.session.get('access_token'); const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; if (access_token) headers['Authorization'] = `Bearer ${access_token}`; let res = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (res.status === 401) { const refreshed = await tryRefreshToken(); if (!refreshed) { signOut(); return null; } const { access_token: tok } = await chrome.storage.session.get('access_token'); headers['Authorization'] = `Bearer ${tok}`; res = await fetch(`${API_BASE}${path}`, { ...options, headers }); } return res; } async function tryRefreshToken() { const { refresh_token } = await chrome.storage.local.get('refresh_token'); if (!refresh_token) return false; try { const res = await fetch(`${API_BASE}/api/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token }), }); if (!res.ok) return false; const data = await res.json(); await chrome.storage.session.set({ access_token: data.access_token }); if (data.refresh_token) await chrome.storage.local.set({ refresh_token: data.refresh_token }); return true; } catch { return false; } } // ── Auth ────────────────────────────────────────────────────────────────────── async function handleLogin() { const email = $('login-email').value.trim().toLowerCase(); const password = $('login-password').value; hideError('login-error'); if (!email || !password) { showError('login-error', 'Email and master password are required.'); return; } const btn = $('btn-login'); btn.disabled = true; btn.textContent = 'Unlocking…'; try { const authHash = await ExtCrypto.deriveAuthHash(password, email); const res = await fetch(`${API_BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, auth_hash: authHash }), }); const data = await res.json(); if (!res.ok) { showError('login-error', data.error || 'Login failed.'); return; } if (data.mfa_required) { _mfaToken = data.mfa_token; await chrome.storage.session.set({ _pending_enc_key_salt: data.enc_key_salt }); handleMfaStage(password); showView('mfa'); $('mfa-code').focus(); return; } await completeLogin(data, password); } catch (err) { showError('login-error', 'Network error: ' + err.message); } finally { btn.disabled = false; btn.textContent = 'Unlock Vault'; } } function handleMfaStage(password) { $('btn-mfa-verify').onclick = async () => { const code = $('mfa-code').value.trim(); hideError('mfa-error'); if (code.length !== 6) { showError('mfa-error', 'Enter the 6-digit code.'); return; } $('btn-mfa-verify').disabled = true; try { const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mfa_token: _mfaToken, totp_code: code }), }); const data = await res.json(); if (!res.ok) { showError('mfa-error', data.error || 'Verification failed.'); return; } _mfaToken = null; const { _pending_enc_key_salt } = await chrome.storage.session.get('_pending_enc_key_salt'); await completeLogin({ ...data, enc_key_salt: _pending_enc_key_salt }, password); } catch (err) { showError('mfa-error', 'Error: ' + err.message); } finally { $('btn-mfa-verify').disabled = false; } }; } async function completeLogin(data, masterPassword) { await chrome.storage.session.set({ access_token: data.access_token, enc_key_salt: data.enc_key_salt }); // enc_key_salt also persisted locally — not sensitive without the master password, // needed to show unlock-only view after browser restart. await chrome.storage.local.set({ refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt }); _vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt); const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey); await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk }); // Push session to any open web app tabs so they don't need to re-login chrome.runtime.sendMessage({ type: 'EXT_SESSION_SYNC', access_token: data.access_token, refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt, }).catch(() => { }); showView('vault'); await checkPendingSave(); await fetchAndDecryptVault(); renderList(); } // ── Unlock-only (tokens from web app, just need master password) ────────────── async function handleUnlockOnly() { const password = $('unlock-password').value; hideError('unlock-error'); if (!password) { showError('unlock-error', 'Enter your master password.'); return; } const btn = $('btn-unlock'); btn.disabled = true; btn.textContent = 'Unlocking…'; try { // enc_key_salt may be in session (fresh) or local (after browser restart) let { enc_key_salt } = await chrome.storage.session.get('enc_key_salt'); if (!enc_key_salt) { const local = await chrome.storage.local.get('enc_key_salt'); enc_key_salt = local.enc_key_salt; } if (!enc_key_salt) { showError('unlock-error', 'Session expired. Please log in again.'); return; } _vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt); const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey); await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk }); showView('vault'); await checkPendingSave(); await fetchAndDecryptVault(); renderList(); } catch { showError('unlock-error', 'Incorrect password or session expired.'); } finally { btn.disabled = false; btn.textContent = 'Unlock'; } } async function signOut() { try { const { access_token } = await chrome.storage.session.get('access_token'); const { refresh_token } = await chrome.storage.local.get('refresh_token'); if (access_token) { fetch(`${API_BASE}/api/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${access_token}` }, body: JSON.stringify({ refresh_token }), }).catch(() => { }); } } catch { } await chrome.storage.session.clear(); await chrome.storage.local.remove(['refresh_token', 'enc_key_salt']); // vault_items_cs is now in session storage — cleared by the session.clear() call above. _vaultKey = null; _items = []; showView('login'); } // ── Vault loading ───────────────────────────────────────────────────────────── /** * Returns: * 'vault' — fully unlocked session restored * 'unlock' — tokens exist (from web app SSO) but vault key needs deriving * false — no session, show login */ async function restoreSessionIfAvailable() { const data = await chrome.storage.session.get(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']); if (data.access_token) { if (data.vault_key_jwk) { _vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk); _items = data.vault_items || []; return 'vault'; } // Tokens exist (synced from web app) but vault key not yet derived if (data.enc_key_salt) return 'unlock'; } // Session cleared (e.g. browser restart) — try refreshing with persisted local tokens const local = await chrome.storage.local.get(['refresh_token', 'enc_key_salt']); if (local.refresh_token && local.enc_key_salt) { const refreshed = await tryRefreshToken(); if (refreshed) { await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt }); return 'unlock'; } } return false; } async function fetchAndDecryptVault() { $('vault-spinner').classList.remove('hidden'); try { const [res, foldersRes] = await Promise.all([ apiFetch('/api/vault'), apiFetch('/api/folders'), ]); if (!res) return; const raw = await res.json(); try { if (foldersRes?.ok) _folders = await foldersRes.json(); } catch (e) { _folders = []; } _items = await Promise.all(raw.map(async item => { try { const plain = await ExtCrypto.decryptItem(_vaultKey, item.enc_data, item.iv); // Decrypt the item name if an encrypted version exists. // Fall back to the server-stored plaintext name for legacy items. let displayName = item.name; if (item.enc_name && item.iv_name) { const decrypted = await ExtCrypto.decryptName(_vaultKey, item.enc_name, item.iv_name); if (decrypted) displayName = decrypted; } return { ...item, name: displayName, plain }; } catch { return { ...item, plain: null }; } })); // Write to session for the popup's own use (badge, rendering). await chrome.storage.session.set({ vault_items: _items }); // Write a lightweight copy to session storage for content scripts. // session storage is memory-only (cleared on browser close) — decrypted // vault data must never be persisted to disk via chrome.storage.local. const itemsForContentScript = _items.map(item => ({ id: item.id, name: item.name, item_type: item.item_type, plain: item.plain, })); await chrome.storage.session.set({ vault_items_cs: itemsForContentScript }); // Notify background to refresh badges and forward to content scripts. chrome.runtime.sendMessage({ type: 'VAULT_UPDATED', vault_items: itemsForContentScript, }).catch(() => { }); } catch (err) { console.error('fetchAndDecryptVault:', err); } finally { $('vault-spinner').classList.add('hidden'); } } // ── TOTP engine (RFC 6238) — pure Web Crypto, no library ───────────────────── function _extractTotpSecret(uri) { if (!uri) return null; uri = uri.trim(); if (uri.startsWith('otpauth://')) { try { const secret = new URL(uri).searchParams.get('secret'); return secret ? secret.toUpperCase().replace(/\s+/g, '') : null; } catch { return null; } } return uri.toUpperCase().replace(/[\s-]/g, '') || null; } function _base32ToBytes(b32) { const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; b32 = b32.replace(/=+$/, ''); let bits = 0, val = 0; const out = []; for (const c of b32) { const idx = CHARS.indexOf(c); if (idx === -1) continue; val = (val << 5) | idx; bits += 5; if (bits >= 8) { bits -= 8; out.push((val >> bits) & 0xff); } } return new Uint8Array(out); } async function getTotpCode(totpUri) { const secret = _extractTotpSecret(totpUri); if (!secret) return null; const keyBytes = _base32ToBytes(secret); if (!keyBytes.length) return null; const counter = Math.floor(Date.now() / 1000 / 30); const msg = new Uint8Array(8); let c = counter; for (let i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); } const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] ); const sig = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, msg)); const offset = sig[19] & 0x0f; const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 1_000_000; return String(code).padStart(6, '0'); } function _totpSecondsLeft() { return 30 - (Math.floor(Date.now() / 1000) % 30); } // Track active TOTP ticker intervals so we can clear them on re-render. let _totpIntervals = []; function _clearTotpTickers() { _totpIntervals.forEach(id => clearInterval(id)); _totpIntervals = []; } // ── Rendering ───────────────────────────────────────────────────────────────── function getTabItems() { const q = $('vault-search').value.trim().toLowerCase(); let items = _items; // Text filter if (q) { items = items.filter(i => i.name.toLowerCase().includes(q) || (i.plain?.username || '').toLowerCase().includes(q) || (i.plain?.url || '').toLowerCase().includes(q) ); } if (_activeTab === 'relevant') { // Show ONLY items that match the current domain, sorted by name. items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name)); } else if (_activeTab === 'recents') { items = [...items].sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)).slice(0, 20); } else if (_activeTab === 'favorites') { items = items.filter(i => (i.plain?.tags || []).includes('favorite')) .sort((a, b) => a.name.localeCompare(b.name)); } else { items = [...items].sort((a, b) => a.name.localeCompare(b.name)); } return items; } function renderList() { const listEl = $('vault-list'); const emptyEl = $('vault-empty'); const items = getTabItems(); // Stop any running TOTP tickers from a previous render. _clearTotpTickers(); if (!items.length) { listEl.innerHTML = ''; if (_activeTab === 'relevant') { const host = currentHostname(); emptyEl.textContent = host ? `No saved passwords for ${host}.` : 'No items match this page.'; } else { emptyEl.textContent = 'No items found.'; } emptyEl.classList.remove('hidden'); return; } emptyEl.classList.add('hidden'); // SVG icons for action buttons const svgCopy = ``; const svgUser = ``; const svgDots = ``; const svgFill = ``; const svgTotp = ``; function buildItemHtml(item) { const matched = isMatch(item); const site = escHtml(siteLabel(item)); const name = escHtml(item.name); const badge = matched ? 'match' : ''; const color = avatarColor(item.name); const emoji = itemEmoji(item.item_type); const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password; const canCopy = item.item_type === 'password' && item.plain?.password; const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri); const tags = (item.plain?.tags || []).filter(t => t !== 'favorite'); const tagHtml = tags.map(t => `${escHtml(t)}`).join(''); const isFav = (item.plain?.tags || []).includes('favorite'); return `
${emoji}
${site}${badge}${isFav ? '' : ''}
${name}
${tagHtml ? `
${tagHtml}
` : ''} ${hasTotp ? `
······
` : ''}
${item.plain?.username ? `` : ''} ${canCopy ? `` : ''} ${hasTotp ? `` : ''} ${canFill ? `` : ''}
`; } // Build the vault list — grouped by folder for 'all' tab, flat otherwise. if (_activeTab === 'all' && _folders.length > 0) { const groups = {}; items.forEach(item => { const key = folderName(item.folder_id); if (!groups[key]) groups[key] = []; groups[key].push(item); }); const keys = ['(none)', ..._folders.map(f => f.name)].filter(k => groups[k]); Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); }); listEl.innerHTML = keys.map(groupName => { if (!groups[groupName]) return ''; const isCollapsed = _collapsedFolders.has(groupName); const count = groups[groupName].length; const chevron = isCollapsed ? '›' : '⌄'; const itemsHtml = isCollapsed ? '' : groups[groupName].map(buildItemHtml).join(''); return `
${escHtml(groupName)} ${count} ${chevron}
${itemsHtml}
`; }).join(''); // Wire collapse toggle on group headers. listEl.querySelectorAll('.pk-group-header').forEach(hdr => { hdr.addEventListener('click', () => { const groupName = hdr.closest('.pk-group').dataset.group; if (_collapsedFolders.has(groupName)) _collapsedFolders.delete(groupName); else _collapsedFolders.add(groupName); renderList(); }); }); } else { listEl.innerHTML = items.map(buildItemHtml).join(''); } // Start TOTP tickers for items that have a totp_uri. items.forEach(item => { if (item.item_type !== 'password' || !_extractTotpSecret(item.plain?.totp_uri)) return; const codeEl = $(`totp-${item.id}`); const timerEl = $(`totp-t-${item.id}`); if (!codeEl) return; async function tick() { const code = await getTotpCode(item.plain.totp_uri).catch(() => null); if (!code || !codeEl.isConnected) return; codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3); const secs = _totpSecondsLeft(); timerEl.textContent = ' ' + secs + 's'; timerEl.style.color = secs <= 5 ? '#dc2626' : '#9ca3af'; } tick(); const id = setInterval(tick, 1000); _totpIntervals.push(id); }); // Copy password listEl.querySelectorAll('[data-copy-pass]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass)); if (item?.plain?.password) { _copyWithAutoClear(item.plain.password); btn.title = 'Copied!'; setTimeout(() => { btn.title = 'Copy password'; }, 1500); } }) ); // Copy TOTP code listEl.querySelectorAll('[data-copy-totp]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const item = _items.find(i => i.id === parseInt(btn.dataset.copyTotp)); if (!item?.plain?.totp_uri) return; const code = await getTotpCode(item.plain.totp_uri).catch(() => null); if (code) { navigator.clipboard.writeText(code); btn.title = 'Copied!'; btn.style.color = '#16a34a'; setTimeout(() => { btn.title = 'Copy 2FA code'; btn.style.color = ''; }, 1500); } }) ); // Autofill listEl.querySelectorAll('[data-autofill]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const item = _items.find(i => i.id === parseInt(btn.dataset.autofill)); if (!item?.plain) return; const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (tab?.id) { chrome.tabs.sendMessage(tab.id, { type: 'DO_AUTOFILL', username: item.plain.username || '', password: item.plain.password || '', }).catch(() => { }); } window.close(); }) ); // Copy username (dedicated button) listEl.querySelectorAll('[data-copy-user]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); const item = _items.find(i => i.id === parseInt(btn.dataset.copyUser)); if (item?.plain?.username) { _copyWithAutoClear(item.plain.username); btn.title = 'Copied!'; setTimeout(() => { btn.title = 'Copy username'; }, 1500); } }) ); // Three-dot menu: proper flyout with contextual actions listEl.querySelectorAll('[data-menu]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); // Close any already-open flyout first. document.querySelectorAll('.pk-flyout').forEach(el => el.remove()); const item = _items.find(i => i.id === parseInt(btn.dataset.menu)); if (!item) return; const flyout = document.createElement('div'); flyout.className = 'pk-flyout'; const menuItems = [ item.plain?.url ? { label: 'Open URL', icon: '', action: () => { chrome.tabs.create({ url: item.plain.url }); flyout.remove(); }, } : null, item.plain?.username ? { label: 'Copy username', icon: '', action: () => { _copyWithAutoClear(item.plain.username); flyout.remove(); }, } : null, item.plain?.password ? { label: 'Copy password', icon: '', action: () => { _copyWithAutoClear(item.plain.password); flyout.remove(); }, } : null, ].filter(Boolean); menuItems.forEach(mi => { const row = document.createElement('button'); row.className = 'pk-flyout-item'; row.innerHTML = mi.icon + '' + escHtml(mi.label) + ''; row.addEventListener('click', e => { e.stopPropagation(); mi.action(); }); flyout.appendChild(row); }); // Position flyout below the button, right-aligned. btn.style.position = 'relative'; btn.appendChild(flyout); // Close on any outside click. setTimeout(() => { document.addEventListener('click', function handler() { flyout.remove(); document.removeEventListener('click', handler); }); }, 0); }) ); } // ── Tabs ────────────────────────────────────────────────────────────────────── function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { _activeTab = btn.dataset.tab; document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn)); renderList(); }); }); } // ── Save-prompt modal ───────────────────────────────────────────────────────── /** * Populate the folder