import SubstitutionEngine from '../lib/substitution-engine.js'; import SmartPatterns from '../lib/smart-patterns.js'; import SecretScanner from '../lib/secret-scanner.js'; import AutoDetect from '../lib/auto-detect.js'; import Storage from '../lib/storage.js'; import SilentSendSync from '../lib/sync.js'; import SilentSendCrypto from '../lib/crypto.js'; import api from '../lib/browser-polyfill.js'; // --- State --- let mappings = []; let identity = {}; // merged identity (all active profiles) let profiles = []; // all profiles let currentProfileId = null; // currently selected profile for editing let settings = {}; // --- DOM refs --- const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); // --- Init --- document.addEventListener('DOMContentLoaded', async () => { // Check if locked BEFORE trying to read sensitive data const locked = await Storage.isLocked(); if (locked) { showLockedUI(); return; } await initUnlockedUI(); }); async function initUnlockedUI() { // Hide locked overlay, show normal UI $('#lockedOverlay').style.display = 'none'; mappings = await Storage.getMappings(); profiles = await Storage.getProfiles(); identity = await Storage.getIdentity(); settings = await Storage.getSettings(); // Initialize profiles — create default with first+last name rows if (profiles.length === 0) { const p = await Storage.addProfile('Personal'); // Pre-populate with first and last name rows await Storage.updateProfile(p.id, { names: [ { real: '', substitute: '', type: 'first' }, { real: '', substitute: '', type: 'last' }, ], }); profiles = await Storage.getProfiles(); } currentProfileId = profiles[0].id; renderProfileSelector(); loadIdentityForm(); renderMappings(); renderActivity(); updateStatusDot(); checkFirstRun(); $('#enableToggle').checked = settings.enabled; // Tab switching $$('.tab').forEach((tab) => { tab.addEventListener('click', () => { $$('.tab').forEach((t) => t.classList.remove('active')); $$('.tab-content').forEach((c) => c.classList.remove('active')); tab.classList.add('active'); $(`#tab-${tab.dataset.tab}`).classList.add('active'); if (tab.dataset.tab === 'activity') renderActivity(); if (tab.dataset.tab === 'test') { // Reload merged identity from storage in case profiles were just saved Storage.getIdentity().then((id) => { identity = id; updateIdentityStatus(); }); } if (tab.dataset.tab === 'identity') { // Reload profiles Storage.getProfiles().then((p) => { profiles = p; renderProfileSelector(); loadIdentityForm(); }); } }); }); // Enable toggle $('#enableToggle').addEventListener('change', async (e) => { settings.enabled = e.target.checked; await Storage.saveSettings(settings); updateStatusDot(); api.runtime.sendMessage({ type: 'update:settings', settings, }); }); // Reveal mode toggle $('#btnReveal').addEventListener('click', async () => { settings.revealMode = !settings.revealMode; await Storage.saveSettings(settings); $('#btnReveal').classList.toggle('active', settings.revealMode); api.runtime.sendMessage({ type: 'update:settings', settings, }); }); $('#btnReveal').classList.toggle('active', settings.revealMode); // Profile controls $('#profileSelect').addEventListener('change', (e) => { currentProfileId = e.target.value; loadIdentityForm(); }); $('#btnAddProfile').addEventListener('click', async () => { const name = prompt('Profile name:', `Profile ${profiles.length + 1}`); if (!name) return; const p = await Storage.addProfile(name); // Pre-populate with first+last name rows await Storage.updateProfile(p.id, { names: [ { real: '', substitute: '', type: 'first' }, { real: '', substitute: '', type: 'last' }, ], }); profiles = await Storage.getProfiles(); currentProfileId = p.id; renderProfileSelector(); loadIdentityForm(); checkFirstRun(); }); $('#btnRenameProfile').addEventListener('click', async () => { const profile = profiles.find(p => p.id === currentProfileId); if (!profile) return; const name = prompt('Rename profile:', profile.name); if (!name) return; await Storage.updateProfile(currentProfileId, { name }); profiles = await Storage.getProfiles(); renderProfileSelector(); }); $('#btnDeleteProfile').addEventListener('click', async () => { if (profiles.length <= 1) { alert('Cannot delete the last profile.'); return; } const profile = profiles.find(p => p.id === currentProfileId); if (!confirm(`Delete profile "${profile?.name}"?`)) return; await Storage.deleteProfile(currentProfileId); profiles = await Storage.getProfiles(); currentProfileId = profiles[0]?.id; renderProfileSelector(); loadIdentityForm(); identity = await Storage.getIdentity(); checkFirstRun(); }); $('#profileActive').addEventListener('change', async (e) => { await Storage.updateProfile(currentProfileId, { active: e.target.checked }); profiles = await Storage.getProfiles(); identity = await Storage.getIdentity(); renderProfileSelector(); checkFirstRun(); }); // Save identity $('#btnSaveIdentity').addEventListener('click', saveIdentity); // Add mapping $('#btnAdd').addEventListener('click', addMapping); $('#inputSub').addEventListener('keydown', (e) => { if (e.key === 'Enter') addMapping(); }); // Clear log $('#btnClearLog').addEventListener('click', async () => { await Storage.clearLog(); renderActivity(); }); // Test tab - live diff + reveal $('#testInput').addEventListener('input', renderTestDiff); $('#revealInput').addEventListener('input', renderRevealDiff); $('#btnCopyRevealed').addEventListener('click', copyRevealedText); // Test mode toggle (strip vs reveal) $$('.test-mode-btn').forEach((btn) => { btn.addEventListener('click', () => { $$('.test-mode-btn').forEach((b) => b.classList.remove('active')); btn.classList.add('active'); const mode = btn.dataset.mode; $('#stripMode').style.display = mode === 'strip' ? 'block' : 'none'; $('#revealMode').style.display = mode === 'reveal' ? 'block' : 'none'; }); }); // Options link $('#btnOptions').addEventListener('click', (e) => { e.preventDefault(); api.runtime.openOptionsPage(); }); // Update privacy note based on encryption state const encEnabled = await Storage._isAtRestEncryptionEnabled(); const encNote = $('#privacyEncNote'); if (encNote) { encNote.style.display = encEnabled ? '' : 'none'; } } // --- Locked UI --- async function showLockedUI() { // Hide all normal UI elements const lockedOverlay = $('#lockedOverlay'); lockedOverlay.style.display = 'block'; // Check if TOTP is configured const encConfig = await SilentSendSync._getSyncEncryption(); if (encConfig?.totpSecret) { $('#unlockTOTP').style.display = ''; } // Check if WebAuthn is available if (encConfig?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { const hasCred = await SilentSendCrypto.hasWebAuthnCredential(); if (hasCred) { $('#btnUnlockBiometric').style.display = ''; } } // Unlock with password (+ optional TOTP) $('#btnUnlock').addEventListener('click', async () => { const password = $('#unlockPassword').value; const totpCode = $('#unlockTOTP').value; if (!password) { $('#unlockStatus').textContent = 'Enter your password.'; return; } $('#unlockStatus').textContent = 'Unlocking...'; $('#unlockStatus').style.color = '#6b7280'; const result = await SilentSendSync.authenticate(password, totpCode || undefined); if (result.success) { $('#unlockStatus').textContent = ''; // Notify background to clear LOCK badge api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {}); // Transition to normal UI await initUnlockedUI(); } else { $('#unlockStatus').textContent = result.reason; $('#unlockStatus').style.color = '#dc2626'; } }); // Enter key triggers unlock $('#unlockPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#btnUnlock').click(); }); $('#unlockTOTP').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#btnUnlock').click(); }); // Unlock with biometric $('#btnUnlockBiometric').addEventListener('click', async () => { $('#unlockStatus').textContent = 'Waiting for biometric...'; $('#unlockStatus').style.color = '#6b7280'; const verified = await SilentSendCrypto.webAuthnAuthenticate(); if (verified) { const ttlDays = encConfig?.ttlDays ?? 90; await SilentSendCrypto.markVerified(ttlDays); // The key should already be in IndexedDB from prior session const cached = await SilentSendCrypto.getCachedKey(); if (cached) { api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {}); await initUnlockedUI(); } else { $('#unlockStatus').textContent = 'Key not found. Enter password.'; $('#unlockStatus').style.color = '#dc2626'; } } else { $('#unlockStatus').textContent = 'Biometric failed.'; $('#unlockStatus').style.color = '#dc2626'; } }); // Focus password field setTimeout(() => $('#unlockPassword').focus(), 100); } // --- Profiles --- function renderProfileSelector() { const select = $('#profileSelect'); select.innerHTML = profiles.map(p => `` ).join(''); const profile = profiles.find(p => p.id === currentProfileId); $('#profileActive').checked = profile?.active ?? true; } // --- Identity (dynamic multi-entry) --- // Field configs for rendering const FIELD_CONFIGS = { names: { container: 'idNames', placeholderReal: 'Real name', placeholderSub: 'Fake name', typeOptions: [{ value: 'first', label: '1st' }, { value: 'last', label: 'Last' }, { value: 'middle', label: 'Mid' }, { value: 'nick', label: 'Nick' }], defaultType: 'first', }, emails: { container: 'idEmails', placeholderReal: 'you@gmail.com', placeholderSub: 'fake@example.com', }, usernames: { container: 'idUsernames', placeholderReal: 'jsmith', placeholderSub: 'ademo', }, hostnames: { container: 'idHostnames', placeholderReal: 'macbook-pro', placeholderSub: 'mycomputer', }, phones: { container: 'idPhones', placeholderReal: '(555) 123-4567', placeholderSub: '(555) 000-0000', }, }; function renderFieldList(fieldName, items) { const config = FIELD_CONFIGS[fieldName]; const container = $(`#${config.container}`); if (!container) return; if (!items || items.length === 0) { // Show one empty row items = [{ real: '', substitute: '', type: config.defaultType || '' }]; } container.innerHTML = items.map((item, i) => { let typeHtml = ''; if (config.typeOptions) { typeHtml = ``; } return `
${typeHtml}
`; }).join(''); // Bind remove buttons container.querySelectorAll('.btn-remove').forEach(btn => { btn.addEventListener('click', () => { const row = btn.closest('.id-entry-row'); if (container.querySelectorAll('.id-entry-row').length > 1) { row.remove(); } else { // Clear instead of remove if it's the last row row.querySelectorAll('input').forEach(inp => inp.value = ''); } }); }); } function loadIdentityForm() { const profile = profiles.find(p => p.id === currentProfileId); if (!profile) return; renderFieldList('names', profile.names || []); renderFieldList('emails', profile.emails || []); renderFieldList('usernames', profile.usernames || []); renderFieldList('hostnames', profile.hostnames || []); renderFieldList('phones', profile.phones || []); $('#idCatchAllEmail').value = profile.catchAllEmail || ''; $('#profileActive').checked = profile.active ?? true; // Bind add buttons $$('.btn-add').forEach(btn => { // Remove old listeners by cloning const newBtn = btn.cloneNode(true); btn.replaceWith(newBtn); newBtn.addEventListener('click', () => { const field = newBtn.dataset.field; const config = FIELD_CONFIGS[field]; const container = $(`#${config.container}`); const count = container.querySelectorAll('.id-entry-row').length; const tempDiv = document.createElement('div'); let typeHtml = ''; if (config.typeOptions) { // Alternate: if first row is 'first', next should be 'last', etc. const defaultType = count % 2 === 0 ? 'first' : 'last'; typeHtml = ``; } tempDiv.innerHTML = `
${typeHtml}
`; const row = tempDiv.firstElementChild; container.appendChild(row); row.querySelector('.btn-remove').addEventListener('click', () => { if (container.querySelectorAll('.id-entry-row').length > 1) row.remove(); else row.querySelectorAll('input').forEach(inp => inp.value = ''); }); row.querySelector('.id-real').focus(); }); }); } function escapeAttr(str) { return str.replace(/&/g, '&').replace(/"/g, '"').replace(/ { const real = row.querySelector('.id-real')?.value.trim() || ''; const sub = row.querySelector('.id-sub')?.value.trim() || ''; if (real && sub) { const entry = { real, substitute: sub }; const typeSelect = row.querySelector('.id-type-select'); if (typeSelect) entry.type = typeSelect.value; entries.push(entry); } }); return entries; } // Track unsaved changes let identityDirty = false; function markDirty() { if (!identityDirty) { identityDirty = true; const btn = $('#btnSaveIdentity'); btn.textContent = 'Save Identity *'; btn.style.background = '#b45309'; } } function markClean() { identityDirty = false; const btn = $('#btnSaveIdentity'); btn.textContent = 'Saved!'; btn.style.background = '#059669'; setTimeout(() => { if (!identityDirty) { btn.textContent = 'Save Identity'; btn.style.background = ''; } }, 1500); } // Listen for input changes in identity tab to mark dirty document.addEventListener('input', (e) => { const target = e.target; if (target.closest('#tab-identity')) { markDirty(); } }, true); // Listen for Enter key in identity fields to save document.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.closest('#tab-identity')) { e.preventDefault(); saveIdentity(); } }, true); async function saveIdentity() { const profileData = { names: readFieldEntries('names'), emails: readFieldEntries('emails'), usernames: readFieldEntries('usernames'), hostnames: readFieldEntries('hostnames'), phones: readFieldEntries('phones'), catchAllEmail: $('#idCatchAllEmail').value.trim(), emailDomains: [], enabled: { emails: true, names: true, usernames: true, phones: true, paths: true }, }; await Storage.updateProfile(currentProfileId, profileData); profiles = await Storage.getProfiles(); identity = await Storage.getIdentity(); checkFirstRun(); markClean(); } // --- First-Run Check --- function checkFirstRun() { const hasNames = (identity.names || []).length > 0; const hasEmails = (identity.emails || []).length > 0 || !!identity.catchAllEmail; const hasUsernames = (identity.usernames || []).length > 0; const hasMappings = mappings.length > 0; // Show banner if nothing is configured at all const isConfigured = hasNames || hasEmails || hasUsernames || hasMappings; const banner = $('#firstRunBanner'); banner.style.display = isConfigured ? 'none' : 'flex'; // Also update the status dot — orange if unconfigured const dot = $('#statusDot'); if (!isConfigured && settings.enabled) { dot.classList.add('off-site'); dot.classList.remove('disabled'); } else { dot.classList.remove('off-site'); } } // --- Add Mapping --- async function addMapping() { const real = $('#inputReal').value.trim(); const sub = $('#inputSub').value.trim(); const category = $('#inputCategory').value; const caseSensitive = $('#inputCaseSensitive').checked; if (!real || !sub) return; const mapping = await Storage.addMapping({ real, substitute: sub, category, caseSensitive, }); mappings.push(mapping); renderMappings(); // Clear inputs $('#inputReal').value = ''; $('#inputSub').value = ''; $('#inputCaseSensitive').checked = false; $('#inputReal').focus(); } // --- Render Mappings --- function renderMappings() { const list = $('#mappingList'); if (mappings.length === 0) { list.innerHTML = '
No mappings yet. Add your first one above.
'; return; } list.innerHTML = mappings .map( (m) => `
${m.category === 'password' ? '••••••••' : escapeHtml(m.real)}${escapeHtml(m.substitute)}
${m.category}
` ) .join(''); // Bind actions list.querySelectorAll('.btn-delete').forEach((btn) => { btn.addEventListener('click', async () => { const id = btn.closest('.mapping-item').dataset.id; await Storage.deleteMapping(id); mappings = mappings.filter((m) => m.id !== id); renderMappings(); }); }); list.querySelectorAll('.btn-toggle').forEach((btn) => { btn.addEventListener('click', async () => { const item = btn.closest('.mapping-item'); const id = item.dataset.id; const mapping = mappings.find((m) => m.id === id); if (!mapping) return; mapping.enabled = !mapping.enabled; await Storage.updateMapping(id, { enabled: mapping.enabled }); renderMappings(); }); }); } // --- Render Activity Log --- async function renderActivity() { const log = await Storage.getLog(); const list = $('#activityList'); const countEl = $('#sessionCount'); countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`; if (log.length === 0) { list.innerHTML = '
No activity yet.
'; return; } list.innerHTML = log .slice(0, 50) .map((entry) => { const time = new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); return `
${time}
${escapeHtml(entry.original)}${escapeHtml(entry.replaced)}
`; }) .join(''); } // --- Test Diff (Strip: real → fake) --- function renderTestDiff() { const input = $('#testInput').value; const output = $('#diffOutput'); const stats = $('#diffStats'); if (!input) { output.innerHTML = ''; stats.textContent = ''; return; } const smartResult = SmartPatterns.substitute(input, identity); const explicitResult = SubstitutionEngine.substitute(smartResult.text, mappings); const secretResult = SecretScanner.redact(explicitResult.text); const allReplacements = [ ...smartResult.replacements, ...explicitResult.replacements, ...secretResult.redactions, ]; const finalText = secretResult.text; if (finalText === input && secretResult.warnings.length === 0) { output.textContent = input; stats.textContent = 'No substitutions detected'; return; } let html = escapeHtml(finalText); // Highlight identity + explicit substitutions in green for (const r of [...smartResult.replacements, ...explicitResult.replacements]) { const escapedReplaced = escapeHtml(r.replaced); html = html.replace( escapedReplaced, `${escapedReplaced}` ); } // Highlight secret redactions in red for (const r of secretResult.redactions) { const escapedReplaced = escapeHtml(r.replaced); html = html.replace( escapedReplaced, `${escapedReplaced}` ); } output.innerHTML = html; const smartCount = smartResult.replacements.length; const explicitCount = explicitResult.replacements.length; const secretCount = secretResult.redactions.length; const warnCount = secretResult.warnings.length; const parts = []; if (smartCount > 0) parts.push(`${smartCount} smart`); if (explicitCount > 0) parts.push(`${explicitCount} explicit`); if (secretCount > 0) parts.push(`${secretCount} secrets redacted`); if (warnCount > 0) parts.push(`${warnCount} warnings`); // Auto-detect unconfigured PPI in the final text const ppiWarnings = AutoDetect.scan(finalText, identity); if (ppiWarnings.length > 0) parts.push(`${ppiWarnings.length} PPI detected`); stats.textContent = `${allReplacements.length} substitution${allReplacements.length !== 1 ? 's' : ''} (${parts.join(', ')})`; // Show PPI warnings below stats if (ppiWarnings.length > 0) { stats.innerHTML += `
Unconfigured PPI detected: ${ppiWarnings.map(w => `
${escapeHtml(w.value)} — ${w.hint}
`).join('')}
`; } } // --- Reveal Diff (fake → real) --- function renderRevealDiff() { const input = $('#revealInput').value; const output = $('#revealOutput'); const stats = $('#revealStats'); if (!input) { output.innerHTML = ''; stats.textContent = ''; return; } // Reverse: substitute → real using SmartPatterns reveal + explicit reveal let result = input; let totalCount = 0; // Reverse explicit mappings (substitute → real) const explicitRevealed = SubstitutionEngine.reveal(result, mappings); // Count explicit reveals for (const m of mappings) { if (!m.enabled || !m.substitute) continue; const regex = new RegExp(escapeRegex(m.substitute), m.caseSensitive ? 'g' : 'gi'); const matches = result.match(regex); if (matches) totalCount += matches.length; } result = explicitRevealed; // Reverse smart patterns (all identity substitutes → real) const allSubs = gatherSmartSubstitutePairs(identity); for (const pair of allSubs) { const regex = new RegExp(escapeRegex(pair.substitute), 'gi'); const matches = result.match(regex); if (matches) totalCount += matches.length; result = result.replace(regex, pair.real); } if (result === input) { output.textContent = input; stats.textContent = 'No substituted values found to reveal'; return; } // Highlight revealed values let html = escapeHtml(result); for (const pair of [...allSubs, ...mappings.filter(m => m.enabled)]) { const real = pair.real; if (!real) continue; const escapedReal = escapeHtml(real); html = html.replace( new RegExp(escapeRegex(escapedReal), 'gi'), `${escapedReal}` ); } output.innerHTML = html; stats.textContent = `${totalCount} value${totalCount !== 1 ? 's' : ''} revealed`; } // Gather all substitute → real pairs from identity for reveal function gatherSmartSubstitutePairs(id) { const pairs = []; for (const e of (id.emails || [])) { if (e.substitute && e.real) pairs.push(e); } if (id.catchAllEmail) { pairs.push({ substitute: id.catchAllEmail, real: '[catch-all]' }); } for (const n of (id.names || [])) { if (n.substitute && n.real) pairs.push(n); } for (const u of (id.usernames || [])) { if (u.substitute && u.real) pairs.push(u); } for (const h of (id.hostnames || [])) { if (h.substitute && h.real) pairs.push(h); } for (const p of (id.phones || [])) { if (p.substitute && p.real) pairs.push(p); } return pairs; } async function copyRevealedText() { const output = $('#revealOutput'); const text = output.textContent; if (!text) return; try { await navigator.clipboard.writeText(text); const btn = $('#btnCopyRevealed'); btn.textContent = 'Copied!'; btn.style.background = '#059669'; setTimeout(() => { btn.textContent = 'Copy to Clipboard'; btn.style.background = ''; }, 1500); } catch (e) { // Fallback const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } } // --- Identity Status --- function updateIdentityStatus() { const el = $('#identityStatus'); const missing = []; if (!(identity.names || []).some(n => n.type === 'first')) missing.push('first name'); if (!(identity.names || []).some(n => n.type === 'last')) missing.push('last name'); if ((identity.emails || []).length === 0 && !identity.catchAllEmail) missing.push('email'); if ((identity.usernames || []).length === 0) missing.push('username'); if (missing.length > 0) { el.textContent = `Identity missing: ${missing.join(', ')}. Go to the Identity tab to set up.`; el.classList.add('visible'); } else { el.classList.remove('visible'); } } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // --- Status Dot --- function updateStatusDot() { const dot = $('#statusDot'); dot.classList.toggle('disabled', !settings.enabled); } // --- Util --- function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }