diff --git a/README.md b/README.md index d4cce7c..578bc91 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,15 @@ All sync channels (browser sync, GitHub Gist, folder sync, custom URL, sync code 3. Full configuration (including TOTP secret) is restored from the encrypted payload 4. WebAuthn credential is registered locally for future re-verification +### Managed browser deployments + +For organizations that want to prevent extension removal: + +- **Chrome / Chromium / Edge:** Use the `ExtensionInstallForcelist` group policy. See [Chrome Enterprise policies](https://chromeenterprise.google/policies/#ExtensionInstallForcelist). +- **Firefox:** Use the `ExtensionSettings` policy in `policies.json` or via Group Policy. See [Firefox Enterprise policies](https://mozilla.github.io/policy-templates/#extensionsettings). + +These are standard browser management features — Silent Send does not attempt to prevent its own removal. + ### Smart reveal Reveal mode only replaces values that were **actually substituted** in outbound messages during the current session. If the AI uses a word that happens to match one of your substitute values (e.g., the AI says "the user should..." and "user" is a configured substitute), it won't be falsely revealed as your real username. diff --git a/src/background/service-worker.js b/src/background/service-worker.js index 095fc5e..1dd59d9 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -159,6 +159,14 @@ const messageHandlers = { api.action.setBadgeBackgroundColor({ color: '#6b7280' }); }, + async 'autosync:config-changed'() { + await setupAutoSyncAlarm(); + }, + + async 'org:config-changed'() { + await setupOrgPolicyAlarm(); + }, + async 'get:locked-state'(_message, _sender, sendResponse) { const locked = await Storage.isLocked(); sendResponse({ locked }); diff --git a/src/options/options.html b/src/options/options.html index 39a0f8d..e0bb909 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -276,6 +276,34 @@
+ +
+

Auto Sync — background polling

+

+ Automatically push and pull settings on an interval. Uses the Gist or URL method configured above. +

+
+ + +
+ + +
+
+
+
+

Custom URL Sync

@@ -297,6 +325,70 @@

+ + + + +
+

Connected Devices

+

Devices that sync with this extension. The device list is shared via sync data.

+
+ + + +
+
+
+ + +
+

Version History

+

Snapshots of your data are saved automatically on each sync. Restore a previous version if a sync overwrote something important.

+
+ + + +
+
+
+ + +
+

Organization

+

Join an organization to receive required substitution rules and secret scanner patterns from your admin. Org rules merge with your personal rules and cannot be disabled.

+ +
+
+ + +
+
+ + +
+
+ + + +
+
+

Transfer Data

Export all your identities, mappings, and settings to a file. Encrypted exports require a password to decrypt.

@@ -379,6 +471,46 @@

Danger Zone

+ + +
+

Tamper Protection

+

+ Require a separate admin password to disable the extension, clear data, or change org settings. + This is a deterrent — it cannot prevent browser-level uninstall or dev tools access. +

+
+
+ + + +
+
+ +
+
+ + + +

Admin Password Required

+

+ +
+ + +
+
+
+
diff --git a/src/options/options.js b/src/options/options.js index 49931a1..0453d6a 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -1,6 +1,10 @@ import Storage from '../lib/storage.js'; import SilentSendCrypto from '../lib/crypto.js'; import SilentSendSync from '../lib/sync.js'; +import VersionHistory from '../lib/version-history.js'; +import SilentSendMerge from '../lib/merge.js'; +import OrgPolicy from '../lib/org-policy.js'; +import TamperGuard from '../lib/tamper-guard.js'; import api from '../lib/browser-polyfill.js'; let mappings = []; @@ -25,6 +29,14 @@ document.addEventListener('DOMContentLoaded', async () => { renderDomains(); renderLog(); + // --- New features --- + await initAutoSyncUI(); + await initVersionHistoryUI(); + await initDeviceDashboard(); + await initOrgUI(); + await initTamperUI(); + await checkConflicts(); + // --- Sync Encryption UI --- await initSyncEncryptionUI(); @@ -1049,6 +1061,446 @@ function setSyncAuthStatus(msg, type) { el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; } +// ---------------------------------------------------------------- +// Auto Sync UI +// ---------------------------------------------------------------- + +async function initAutoSyncUI() { + const config = await SilentSendSync.getAutoSyncConfig(); + if (config) { + $('#autoSyncEnabled').checked = config.enabled || false; + $('#autoSyncMethod').value = config.method || 'gist'; + $('#autoSyncInterval').value = String(config.interval || 15); + if (config.lastPull) { + setAutoSyncStatus(`Last pull: ${new Date(config.lastPull).toLocaleString()}`, 'ok'); + } + } + + const saveAutoSync = async () => { + const config = (await SilentSendSync.getAutoSyncConfig()) || {}; + config.enabled = $('#autoSyncEnabled').checked; + config.method = $('#autoSyncMethod').value; + config.interval = parseInt($('#autoSyncInterval').value, 10) || 15; + + // Inherit token/URL from existing fields + if (config.method === 'gist') { + const token = $('#gistToken').value.trim(); + if (token) config.gistToken = token; + } else { + config.url = $('#customSyncUrl').value.trim(); + config.headers = parseHeadersField($('#customSyncHeaders').value); + } + + await SilentSendSync.saveAutoSyncConfig(config); + // Tell service worker to reconfigure alarm + api.runtime.sendMessage({ type: 'autosync:config-changed' }).catch(() => {}); + setAutoSyncStatus(config.enabled ? 'Auto sync enabled.' : 'Auto sync disabled.', config.enabled ? 'ok' : 'neutral'); + }; + + $('#autoSyncEnabled').addEventListener('change', saveAutoSync); + $('#autoSyncMethod').addEventListener('change', saveAutoSync); + $('#autoSyncInterval').addEventListener('change', saveAutoSync); +} + +function setAutoSyncStatus(msg, type) { + const el = $('#autoSyncStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + +// ---------------------------------------------------------------- +// Version History UI +// ---------------------------------------------------------------- + +async function initVersionHistoryUI() { + $('#maxVersionHistory').value = settings.maxVersionHistory || 10; + $('#maxVersionHistory').addEventListener('change', async (e) => { + await Storage.saveSettings({ maxVersionHistory: parseInt(e.target.value, 10) || 10 }); + }); + + $('#btnClearVersionHistory').addEventListener('click', async () => { + if (!confirm('Clear all version history snapshots?')) return; + await VersionHistory.clearAll(); + renderVersionHistory(); + }); + + await renderVersionHistory(); +} + +async function renderVersionHistory() { + const list = $('#versionHistoryList'); + const snapshots = await VersionHistory.getSnapshots(); + + if (snapshots.length === 0) { + list.innerHTML = '
No snapshots yet. Snapshots are created on each sync.
'; + return; + } + + list.innerHTML = snapshots.map(s => { + const time = new Date(s.timestamp).toLocaleString(); + const mappingCount = (s.data?.mappings || []).length; + return `
+
+ ${time} + via ${escapeHtml(s.source || 'unknown')} + ${mappingCount} mappings +
+ +
`; + }).join(''); + + list.querySelectorAll('.btn-restore-snapshot').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id, 10); + if (!confirm('Restore this snapshot? Current data will be overwritten.')) return; + const snapshot = await VersionHistory.getSnapshot(id); + if (snapshot?.data) { + await SilentSendSync._applyData(snapshot.data, 'rollback'); + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + renderMappings(); + renderDomains(); + renderLog(); + alert('Restored. Reload open AI tabs for changes to take effect.'); + } + }); + }); +} + +// ---------------------------------------------------------------- +// Connected Devices UI +// ---------------------------------------------------------------- + +async function initDeviceDashboard() { + const deviceInfo = await SilentSendSync.getDeviceInfo(); + $('#deviceName').value = deviceInfo.name; + + $('#btnRenameDevice').addEventListener('click', async () => { + const name = $('#deviceName').value.trim(); + if (!name) return; + await SilentSendSync.setDeviceName(name); + renderDevices(); + }); + + await renderDevices(); +} + +async function renderDevices() { + const list = $('#deviceList'); + const devices = await SilentSendSync.getDevices(); + const currentDevice = await SilentSendSync.getDeviceInfo(); + const entries = Object.values(devices); + + if (entries.length === 0) { + list.innerHTML = '
No devices synced yet. Push or pull to register this device.
'; + return; + } + + entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0)); + + list.innerHTML = ` + + + + + + + ${entries.map(d => { + const isCurrent = d.id === currentDevice.id; + const lastSync = d.lastSync ? new Date(d.lastSync).toLocaleString() : 'Never'; + return ` + + + + + `; + }).join('')} +
DeviceBrowserLast Sync
${escapeHtml(d.name || 'Unknown')} ${isCurrent ? '(this)' : ''}${escapeHtml(d.browser || '?')}${lastSync}${!isCurrent ? `` : ''}
`; + + list.querySelectorAll('.btn-remove-device').forEach(btn => { + btn.addEventListener('click', async () => { + await SilentSendSync.removeDevice(btn.dataset.id); + renderDevices(); + }); + }); +} + +// ---------------------------------------------------------------- +// Organization UI +// ---------------------------------------------------------------- + +async function initOrgUI() { + const inOrg = await OrgPolicy.isInOrg(); + if (inOrg) { + await showOrgJoined(); + } else { + showOrgNotJoined(); + } + + $('#btnJoinOrgCode').addEventListener('click', async () => { + const code = $('#orgInviteCode').value.trim(); + if (!code) { setOrgStatus('Enter an invite code.', 'warn'); return; } + setOrgStatus('Joining...', 'neutral'); + const result = await OrgPolicy.joinOrg({ inviteCode: code }); + if (result.success) { + setOrgStatus(`Joined ${result.orgName}.`, 'ok'); + await showOrgJoined(); + } else { + setOrgStatus('Failed: ' + result.reason, 'error'); + } + }); + + $('#btnJoinOrgUrl').addEventListener('click', async () => { + const url = $('#orgPolicyUrl').value.trim(); + if (!url) { setOrgStatus('Enter a policy URL.', 'warn'); return; } + setOrgStatus('Joining...', 'neutral'); + const result = await OrgPolicy.joinOrg({ policyUrl: url }); + if (result.success) { + setOrgStatus(`Joined ${result.orgName}.`, 'ok'); + await showOrgJoined(); + } else { + setOrgStatus('Failed: ' + result.reason, 'error'); + } + }); + + $('#btnLeaveOrg').addEventListener('click', async () => { + // Check tamper protection + if (await TamperGuard.isActionProtected('changeOrgPolicy')) { + const pw = await promptAdminPassword('Leave organization'); + if (!pw) return; + const auth = await TamperGuard.verify(pw); + if (!auth) { setOrgStatus('Wrong admin password.', 'error'); return; } + } + if (!confirm('Leave this organization? Org-required mappings will be removed.')) return; + await OrgPolicy.leaveOrg(); + showOrgNotJoined(); + setOrgStatus('Left organization.', 'neutral'); + }); +} + +async function showOrgJoined() { + $('#orgNotJoined').style.display = 'none'; + $('#orgJoined').style.display = 'block'; + + const config = await OrgPolicy.getOrgConfig(); + const policy = await OrgPolicy.getPolicy(); + if (config) { + $('#orgNameDisplay').textContent = config.orgName; + $('#orgPolicyVersion').textContent = `v${policy?.version || '?'}`; + } + + const compliance = await OrgPolicy.checkCompliance(); + const statusEl = $('#orgComplianceStatus'); + if (compliance.compliant) { + statusEl.innerHTML = '✓ Compliant — all required fields configured'; + } else { + statusEl.innerHTML = `Missing: ${compliance.missing.join(', ')}`; + } + + const reqMappings = policy?.requiredMappings || []; + const reqEl = $('#orgRequiredMappings'); + if (reqMappings.length > 0) { + reqEl.textContent = `${reqMappings.length} required mapping(s) enforced by org policy`; + } else { + reqEl.textContent = ''; + } +} + +function showOrgNotJoined() { + $('#orgNotJoined').style.display = 'block'; + $('#orgJoined').style.display = 'none'; +} + +function setOrgStatus(msg, type) { + const el = $('#orgStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + +// ---------------------------------------------------------------- +// Tamper Protection UI +// ---------------------------------------------------------------- + +async function initTamperUI() { + const enabled = await TamperGuard.isEnabled(); + if (enabled) { + $('#tamperNotEnabled').style.display = 'none'; + $('#tamperEnabled').style.display = 'block'; + } + + $('#btnEnableTamper').addEventListener('click', async () => { + const pw = $('#tamperAdminPassword').value; + const confirm = $('#tamperAdminPasswordConfirm').value; + if (!pw) { setTamperStatus('Enter a password.', 'warn'); return; } + if (pw !== confirm) { setTamperStatus('Passwords do not match.', 'error'); return; } + + const result = await TamperGuard.setup(pw); + if (result.success) { + $('#tamperNotEnabled').style.display = 'none'; + $('#tamperEnabled').style.display = 'block'; + $('#tamperAdminPassword').value = ''; + $('#tamperAdminPasswordConfirm').value = ''; + setTamperStatus('Tamper protection enabled.', 'ok'); + } else { + setTamperStatus(result.reason, 'error'); + } + }); + + $('#btnDisableTamper').addEventListener('click', async () => { + const pw = await promptAdminPassword('Disable tamper protection'); + if (!pw) return; + const result = await TamperGuard.disable(pw); + if (result.success) { + $('#tamperNotEnabled').style.display = 'block'; + $('#tamperEnabled').style.display = 'none'; + setTamperStatus('Tamper protection disabled.', 'neutral'); + } else { + setTamperStatus(result.reason, 'error'); + } + }); + + $('#btnChangeTamperPassword').addEventListener('click', async () => { + const oldPw = await promptAdminPassword('Change admin password'); + if (!oldPw) return; + const newPw = window.prompt('Enter new admin password:'); + if (!newPw) return; + const confirmPw = window.prompt('Confirm new admin password:'); + if (newPw !== confirmPw) { setTamperStatus('Passwords do not match.', 'error'); return; } + const result = await TamperGuard.changePassword(oldPw, newPw); + if (result.success) { + setTamperStatus('Admin password changed.', 'ok'); + } else { + setTamperStatus(result.reason, 'error'); + } + }); +} + +function setTamperStatus(msg, type) { + const el = $('#tamperStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + +/** + * Show the admin auth dialog and return the password, or null if cancelled. + */ +function promptAdminPassword(reason) { + return new Promise((resolve) => { + const dialog = $('#adminAuthDialog'); + $('#adminAuthReason').textContent = reason; + $('#adminAuthInput').value = ''; + $('#adminAuthStatus').textContent = ''; + dialog.showModal(); + + const submit = () => { + const pw = $('#adminAuthInput').value; + if (!pw) { + $('#adminAuthStatus').textContent = 'Enter password.'; + return; + } + dialog.close(); + cleanup(); + resolve(pw); + }; + + const cancel = () => { + dialog.close(); + cleanup(); + resolve(null); + }; + + const onKey = (e) => { if (e.key === 'Enter') submit(); }; + + const cleanup = () => { + $('#btnAdminAuthSubmit').removeEventListener('click', submit); + $('#btnAdminAuthCancel').removeEventListener('click', cancel); + $('#adminAuthInput').removeEventListener('keydown', onKey); + }; + + $('#btnAdminAuthSubmit').addEventListener('click', submit); + $('#btnAdminAuthCancel').addEventListener('click', cancel); + $('#adminAuthInput').addEventListener('keydown', onKey); + setTimeout(() => $('#adminAuthInput').focus(), 100); + }); +} + +// ---------------------------------------------------------------- +// Conflict Resolution UI +// ---------------------------------------------------------------- + +async function checkConflicts() { + const result = await api.storage.local.get('ss_sync_conflicts'); + const conflicts = result.ss_sync_conflicts || []; + const section = $('#conflictSection'); + + if (conflicts.length === 0) { + section.style.display = 'none'; + return; + } + + section.style.display = 'block'; + renderConflicts(conflicts); +} + +function renderConflicts(conflicts) { + const list = $('#conflictList'); + list.innerHTML = conflicts.map(c => ` +
+
${escapeHtml(c.path)}
+
+
+
LOCAL (this device)
+ ${escapeHtml(JSON.stringify(c.localValue))} +
+
+
REMOTE (other device)
+ ${escapeHtml(JSON.stringify(c.remoteValue))} +
+
+
+ + +
+
+ `).join(''); + + list.querySelectorAll('.btn-resolve').forEach(btn => { + btn.addEventListener('click', async () => { + const conflictId = btn.dataset.id; + const choice = btn.dataset.choice; + + const result = await api.storage.local.get('ss_sync_conflicts'); + const conflicts = result.ss_sync_conflicts || []; + const conflict = conflicts.find(c => c.id === conflictId); + + if (conflict) { + // Apply resolution + const local = await SilentSendSync._getAllData(); + SilentSendMerge.resolveConflict(local, conflict, choice); + await SilentSendSync._applyData(local, 'conflict-resolution'); + + // Remove resolved conflict + const remaining = conflicts.filter(c => c.id !== conflictId); + await api.storage.local.set({ ss_sync_conflicts: remaining }); + + // Refresh + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + renderMappings(); + renderDomains(); + checkConflicts(); + } + }); + }); +} + +// ---------------------------------------------------------------- +// Utility +// ---------------------------------------------------------------- + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str;