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/manifest.firefox.json b/manifest.firefox.json index 81cc5bd..d6b55eb 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -13,7 +13,8 @@ "storage", "activeTab", "scripting", - "notifications" + "notifications", + "alarms" ], "host_permissions": [ "https://claude.ai/*", diff --git a/manifest.json b/manifest.json index d73335f..5f069a4 100644 --- a/manifest.json +++ b/manifest.json @@ -7,7 +7,8 @@ "storage", "activeTab", "scripting", - "notifications" + "notifications", + "alarms" ], "host_permissions": [ "https://claude.ai/*", diff --git a/src/background/service-worker.js b/src/background/service-worker.js index 2d46e2a..1dd59d9 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -8,8 +8,14 @@ import Storage from '../lib/storage.js'; import SilentSendSync from '../lib/sync.js'; +import OrgPolicy from '../lib/org-policy.js'; +import TamperGuard from '../lib/tamper-guard.js'; import api from '../lib/browser-polyfill.js'; +// --- Alarm names --- +const AUTO_SYNC_ALARM = 'ss-auto-sync'; +const ORG_POLICY_ALARM = 'ss-org-policy'; + // Track substitution counts per tab const tabCounts = new Map(); @@ -153,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 }); @@ -398,11 +412,84 @@ api.notifications.onClicked.addListener((notificationId) => { } }); +// --- Alarms — background polling (MV3-safe, survives service worker restarts) --- + +async function setupAutoSyncAlarm() { + const config = await SilentSendSync.getAutoSyncConfig(); + if (config?.enabled) { + api.alarms.create(AUTO_SYNC_ALARM, { + periodInMinutes: config.interval || 15, + }); + } else { + api.alarms.clear(AUTO_SYNC_ALARM).catch(() => {}); + } +} + +async function setupOrgPolicyAlarm() { + const inOrg = await OrgPolicy.isInOrg(); + if (inOrg) { + api.alarms.create(ORG_POLICY_ALARM, { periodInMinutes: 60 }); + } else { + api.alarms.clear(ORG_POLICY_ALARM).catch(() => {}); + } +} + +api.alarms.onAlarm.addListener(async (alarm) => { + // Skip if locked + const locked = await Storage.isLocked(); + if (locked) return; + + if (alarm.name === AUTO_SYNC_ALARM) { + const result = await SilentSendSync.performAutoSync(); + if (result.pulled) { + console.log('[Silent Send] Auto-sync pulled new data'); + } + if (result.error) { + console.warn('[Silent Send] Auto-sync error:', result.error); + } + } + + if (alarm.name === ORG_POLICY_ALARM) { + const result = await OrgPolicy.fetchPolicy(); + if (result.updated) { + console.log('[Silent Send] Org policy updated to version', result.version); + // Notify content scripts of potential new mappings + broadcastSettings(await Storage.getSettings()); + } + } +}); + +// --- Tamper guard message handlers --- +const tamperHandlers = { + async 'tamper:check-action'(message, _sender, sendResponse) { + const result = await TamperGuard.requireAuth(message.action, message.adminPassword); + sendResponse(result); + }, + + async 'tamper:is-enabled'(_message, _sender, sendResponse) { + const enabled = await TamperGuard.isEnabled(); + sendResponse({ enabled }); + }, +}; + +// Add tamper handlers to main message handler +const origHandler = api.runtime.onMessage._listeners?.[0]; +api.runtime.onMessage.addListener((message, sender, sendResponse) => { + const handler = tamperHandlers[message.type]; + if (handler) { + handler(message, sender, sendResponse); + return true; + } +}); + // --- Set initial state --- api.runtime.onInstalled.addListener(async () => { api.action.setBadgeBackgroundColor({ color: '#6b7280' }); const settings = await Storage.getSettings(); await updateIcon(settings); + // Set up alarms on install + await setupAutoSyncAlarm(); + await setupOrgPolicyAlarm(); }); // Also set icon on startup (service worker wake) + pull any newer sync data @@ -428,4 +515,8 @@ api.runtime.onInstalled.addListener(async () => { if (settings.browserSync) { await SilentSendSync.pullFromSyncStorage(); } + + // Set up periodic alarms + await setupAutoSyncAlarm(); + await setupOrgPolicyAlarm(); })(); diff --git a/src/lib/merge.js b/src/lib/merge.js new file mode 100644 index 0000000..fd0bc13 --- /dev/null +++ b/src/lib/merge.js @@ -0,0 +1,704 @@ +/** + * Silent Send - Three-Way Field-Level Merge + * + * Handles conflict resolution when syncing data between devices. + * Instead of "newest wins", compares an ancestor (last synced state) + * against both local and remote changes, auto-merges non-conflicting + * fields, and surfaces true conflicts for the user to resolve. + * + * Data structures merged: + * - identity: { profiles: [{ id, name, active, emails, names, ... }] } + * - mappings: [{ id, real, substitute, category, ... }] + * - settings: { enabled, showHighlights, customDomains, ... } + */ + +/** + * Deep-equal comparison for two values (objects, arrays, primitives). + * Good enough for JSON-serializable sync data. + * @param {*} a + * @param {*} b + * @returns {boolean} + */ +function deepEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return a === b; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + return a.every((v, i) => deepEqual(v, b[i])); + } + + if (typeof a === 'object') { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => deepEqual(a[k], b[k])); + } + + return false; +} + +/** + * Deep clone a JSON-serializable value. + * @param {*} v + * @returns {*} + */ +function clone(v) { + if (v == null || typeof v !== 'object') return v; + return JSON.parse(JSON.stringify(v)); +} + +const SilentSendMerge = { + /** + * Three-way merge of all sync data. + * + * @param {{ identity: object, mappings: Array, settings: object }} ancestor - last synced state + * @param {{ identity: object, mappings: Array, settings: object }} local - current local state + * @param {{ identity: object, mappings: Array, settings: object }} remote - incoming remote state + * @returns {{ merged: { identity: object, mappings: Array, settings: object }, conflicts: Array<{ id: string, path: string, localValue: *, remoteValue: *, ancestorValue: * }> }} + */ + mergeData(ancestor, local, remote) { + const mappingsResult = this._mergeMappings( + ancestor?.mappings || [], + local?.mappings || [], + remote?.mappings || [], + ); + + const settingsResult = this._mergeSettings( + ancestor?.settings || {}, + local?.settings || {}, + remote?.settings || {}, + ); + + const identityResult = this._mergeIdentity( + ancestor?.identity || {}, + local?.identity || {}, + remote?.identity || {}, + ); + + return { + merged: { + identity: identityResult.merged, + mappings: mappingsResult.merged, + settings: settingsResult.merged, + }, + conflicts: [ + ...mappingsResult.conflicts, + ...settingsResult.conflicts, + ...identityResult.conflicts, + ], + }; + }, + + /** + * Merge mappings arrays by `id` field. + * + * Rules: + * - New on one side only -> auto-add + * - Deleted on one side, unchanged on other -> auto-delete + * - Deleted on one side, changed on other -> conflict + * - Changed on both sides -> conflict (unless changes are identical) + * - Changed on one side only -> accept the change + * + * @param {Array} ancestor + * @param {Array} local + * @param {Array} remote + * @returns {{ merged: Array, conflicts: Array }} + */ + _mergeMappings(ancestor, local, remote) { + const ancestorMap = new Map(ancestor.map((m) => [m.id, m])); + const localMap = new Map(local.map((m) => [m.id, m])); + const remoteMap = new Map(remote.map((m) => [m.id, m])); + + const allIds = new Set([ + ...ancestorMap.keys(), + ...localMap.keys(), + ...remoteMap.keys(), + ]); + + const merged = []; + const conflicts = []; + + for (const id of allIds) { + const a = ancestorMap.get(id); + const l = localMap.get(id); + const r = remoteMap.get(id); + + const inA = ancestorMap.has(id); + const inL = localMap.has(id); + const inR = remoteMap.has(id); + + // New on local only (not in ancestor or remote) + if (!inA && inL && !inR) { + merged.push(clone(l)); + continue; + } + + // New on remote only + if (!inA && !inL && inR) { + merged.push(clone(r)); + continue; + } + + // New on both sides (same id independently — unlikely with UUIDs but handle it) + if (!inA && inL && inR) { + if (deepEqual(l, r)) { + merged.push(clone(l)); + } else { + // Both added with same id but different content — conflict + merged.push(clone(l)); + conflicts.push({ + id, + path: `mappings[${id}]`, + localValue: clone(l), + remoteValue: clone(r), + ancestorValue: null, + }); + } + continue; + } + + // Existed in ancestor + if (inA) { + const localChanged = !deepEqual(a, l); + const remoteChanged = !deepEqual(a, r); + const localDeleted = !inL; + const remoteDeleted = !inR; + + // Deleted on both sides + if (localDeleted && remoteDeleted) { + continue; // gone + } + + // Deleted locally, unchanged remotely -> delete + if (localDeleted && !remoteChanged) { + continue; + } + + // Deleted remotely, unchanged locally -> delete + if (remoteDeleted && !localChanged) { + continue; + } + + // Deleted on one side but changed on other -> conflict + if (localDeleted && remoteChanged) { + conflicts.push({ + id, + path: `mappings[${id}]`, + localValue: null, + remoteValue: clone(r), + ancestorValue: clone(a), + }); + // Keep the remote version in merged for now (user must resolve) + merged.push(clone(r)); + continue; + } + if (remoteDeleted && localChanged) { + conflicts.push({ + id, + path: `mappings[${id}]`, + localValue: clone(l), + remoteValue: null, + ancestorValue: clone(a), + }); + merged.push(clone(l)); + continue; + } + + // Both present + if (!localChanged && !remoteChanged) { + merged.push(clone(a)); // unchanged + } else if (localChanged && !remoteChanged) { + merged.push(clone(l)); + } else if (!localChanged && remoteChanged) { + merged.push(clone(r)); + } else { + // Both changed + if (deepEqual(l, r)) { + merged.push(clone(l)); // same change on both sides + } else { + merged.push(clone(l)); // default to local, flag conflict + conflicts.push({ + id, + path: `mappings[${id}]`, + localValue: clone(l), + remoteValue: clone(r), + ancestorValue: clone(a), + }); + } + } + } + } + + return { merged, conflicts }; + }, + + /** + * Merge settings objects by key. + * + * `customDomains` is treated specially: union of both sides (deduplicated). + * All other keys use standard three-way field comparison. + * + * @param {object} ancestor + * @param {object} local + * @param {object} remote + * @returns {{ merged: object, conflicts: Array }} + */ + _mergeSettings(ancestor, local, remote) { + const merged = {}; + const conflicts = []; + + const allKeys = new Set([ + ...Object.keys(ancestor), + ...Object.keys(local), + ...Object.keys(remote), + ]); + + for (const key of allKeys) { + const a = ancestor[key]; + const l = local[key]; + const r = remote[key]; + + // Special handling for customDomains — union both sides + if (key === 'customDomains') { + const localDomains = Array.isArray(l) ? l : []; + const remoteDomains = Array.isArray(r) ? r : []; + merged[key] = [...new Set([...localDomains, ...remoteDomains])]; + continue; + } + + // Special handling for categories — union both sides + if (key === 'categories') { + const localCats = Array.isArray(l) ? l : []; + const remoteCats = Array.isArray(r) ? r : []; + merged[key] = [...new Set([...localCats, ...remoteCats])]; + continue; + } + + const localChanged = !deepEqual(a, l); + const remoteChanged = !deepEqual(a, r); + + if (!localChanged && !remoteChanged) { + merged[key] = clone(a); // unchanged + } else if (localChanged && !remoteChanged) { + merged[key] = clone(l); + } else if (!localChanged && remoteChanged) { + merged[key] = clone(r); + } else { + // Both changed + if (deepEqual(l, r)) { + merged[key] = clone(l); // same change + } else { + merged[key] = clone(l); // default to local, flag conflict + conflicts.push({ + id: key, + path: `settings.${key}`, + localValue: clone(l), + remoteValue: clone(r), + ancestorValue: clone(a), + }); + } + } + } + + return { merged, conflicts }; + }, + + /** + * Merge identity objects. + * + * Profiles are matched by `id`. Within each profile, top-level scalar + * fields (name, active, catchAllEmail, etc.) use three-way comparison. + * Array fields (emails, names, usernames, hostnames, phones, emailDomains) + * are compared by index position for simplicity. + * + * @param {object} ancestor + * @param {object} local + * @param {object} remote + * @returns {{ merged: object, conflicts: Array }} + */ + _mergeIdentity(ancestor, local, remote) { + const ancestorProfiles = ancestor?.profiles || []; + const localProfiles = local?.profiles || []; + const remoteProfiles = remote?.profiles || []; + + const ancestorMap = new Map(ancestorProfiles.map((p) => [p.id, p])); + const localMap = new Map(localProfiles.map((p) => [p.id, p])); + const remoteMap = new Map(remoteProfiles.map((p) => [p.id, p])); + + const allIds = new Set([ + ...ancestorMap.keys(), + ...localMap.keys(), + ...remoteMap.keys(), + ]); + + const mergedProfiles = []; + const conflicts = []; + + for (const id of allIds) { + const a = ancestorMap.get(id); + const l = localMap.get(id); + const r = remoteMap.get(id); + + const inA = ancestorMap.has(id); + const inL = localMap.has(id); + const inR = remoteMap.has(id); + + // New on one side only + if (!inA && inL && !inR) { + mergedProfiles.push(clone(l)); + continue; + } + if (!inA && !inL && inR) { + mergedProfiles.push(clone(r)); + continue; + } + if (!inA && inL && inR) { + if (deepEqual(l, r)) { + mergedProfiles.push(clone(l)); + } else { + mergedProfiles.push(clone(l)); + conflicts.push({ + id, + path: `identity.profiles[${id}]`, + localValue: clone(l), + remoteValue: clone(r), + ancestorValue: null, + }); + } + continue; + } + + if (!inA) continue; + + const localDeleted = !inL; + const remoteDeleted = !inR; + + // Both deleted + if (localDeleted && remoteDeleted) continue; + + // Deleted on one side, check if other side changed + if (localDeleted) { + if (deepEqual(a, r)) { + continue; // deleted locally, unchanged remotely -> delete + } + conflicts.push({ + id, + path: `identity.profiles[${id}]`, + localValue: null, + remoteValue: clone(r), + ancestorValue: clone(a), + }); + mergedProfiles.push(clone(r)); + continue; + } + if (remoteDeleted) { + if (deepEqual(a, l)) { + continue; // deleted remotely, unchanged locally -> delete + } + conflicts.push({ + id, + path: `identity.profiles[${id}]`, + localValue: clone(l), + remoteValue: null, + ancestorValue: clone(a), + }); + mergedProfiles.push(clone(l)); + continue; + } + + // Both present — merge field by field within the profile + const mergedProfile = this._mergeProfile(id, a, l, r, conflicts); + mergedProfiles.push(mergedProfile); + } + + return { + merged: { profiles: mergedProfiles }, + conflicts, + }; + }, + + /** + * Merge a single identity profile field by field. + * + * Scalar fields: standard three-way comparison. + * Array fields (emails, names, etc.): compared by index position. + * The `enabled` sub-object: merged key by key. + * + * @param {string} profileId + * @param {object} ancestor + * @param {object} local + * @param {object} remote + * @param {Array} conflicts - mutated; conflicts are pushed here + * @returns {object} merged profile + */ + _mergeProfile(profileId, ancestor, local, remote, conflicts) { + const merged = { id: profileId }; + const arrayFields = new Set(['emails', 'names', 'usernames', 'hostnames', 'phones', 'emailDomains']); + const allKeys = new Set([ + ...Object.keys(ancestor), + ...Object.keys(local), + ...Object.keys(remote), + ]); + + for (const key of allKeys) { + if (key === 'id') continue; + + const a = ancestor[key]; + const l = local[key]; + const r = remote[key]; + + // Merge `enabled` sub-object key by key + if (key === 'enabled' && typeof l === 'object' && typeof r === 'object') { + merged[key] = this._mergeEnabledFlags(profileId, a || {}, l, r, conflicts); + continue; + } + + // Array fields — compare by index + if (arrayFields.has(key)) { + merged[key] = this._mergeArray(profileId, key, a || [], l || [], r || [], conflicts); + continue; + } + + // Scalar fields + const localChanged = !deepEqual(a, l); + const remoteChanged = !deepEqual(a, r); + + if (!localChanged && !remoteChanged) { + merged[key] = clone(a); + } else if (localChanged && !remoteChanged) { + merged[key] = clone(l); + } else if (!localChanged && remoteChanged) { + merged[key] = clone(r); + } else { + if (deepEqual(l, r)) { + merged[key] = clone(l); + } else { + merged[key] = clone(l); + conflicts.push({ + id: `${profileId}.${key}`, + path: `identity.profiles[${profileId}].${key}`, + localValue: clone(l), + remoteValue: clone(r), + ancestorValue: clone(a), + }); + } + } + } + + return merged; + }, + + /** + * Merge the `enabled` flags sub-object within a profile. + * + * @param {string} profileId + * @param {object} ancestor + * @param {object} local + * @param {object} remote + * @param {Array} conflicts + * @returns {object} + */ + _mergeEnabledFlags(profileId, ancestor, local, remote, conflicts) { + const merged = {}; + const allKeys = new Set([ + ...Object.keys(ancestor), + ...Object.keys(local), + ...Object.keys(remote), + ]); + + for (const key of allKeys) { + const a = ancestor[key]; + const l = local[key]; + const r = remote[key]; + + const localChanged = a !== l; + const remoteChanged = a !== r; + + if (!localChanged && !remoteChanged) { + merged[key] = a; + } else if (localChanged && !remoteChanged) { + merged[key] = l; + } else if (!localChanged && remoteChanged) { + merged[key] = r; + } else { + if (l === r) { + merged[key] = l; + } else { + merged[key] = l; + conflicts.push({ + id: `${profileId}.enabled.${key}`, + path: `identity.profiles[${profileId}].enabled.${key}`, + localValue: l, + remoteValue: r, + ancestorValue: a, + }); + } + } + } + + return merged; + }, + + /** + * Merge an array field within a profile by index position. + * + * Entries that exist at the same index in ancestor, local, and remote + * are compared field by field. New entries appended on either side are + * added. Entries removed from one side but unchanged on other are removed. + * + * For simplicity, if the array lengths diverge in complex ways, we fall + * back to whole-array comparison and flag a conflict if both changed. + * + * @param {string} profileId + * @param {string} fieldName + * @param {Array} ancestor + * @param {Array} local + * @param {Array} remote + * @param {Array} conflicts + * @returns {Array} + */ + _mergeArray(profileId, fieldName, ancestor, local, remote, conflicts) { + const maxLen = Math.max(ancestor.length, local.length, remote.length); + + // Simple case: neither side changed + if (deepEqual(ancestor, local) && deepEqual(ancestor, remote)) { + return clone(ancestor); + } + + // Only one side changed + if (deepEqual(ancestor, local) && !deepEqual(ancestor, remote)) { + return clone(remote); + } + if (!deepEqual(ancestor, local) && deepEqual(ancestor, remote)) { + return clone(local); + } + + // Both changed — try index-level merge + const merged = []; + let hasConflict = false; + + for (let i = 0; i < maxLen; i++) { + const a = i < ancestor.length ? ancestor[i] : undefined; + const l = i < local.length ? local[i] : undefined; + const r = i < remote.length ? remote[i] : undefined; + + const localChanged = !deepEqual(a, l); + const remoteChanged = !deepEqual(a, r); + + if (!localChanged && !remoteChanged) { + if (a !== undefined) merged.push(clone(a)); + } else if (localChanged && !remoteChanged) { + if (l !== undefined) merged.push(clone(l)); + // l undefined means local deleted this index — skip + } else if (!localChanged && remoteChanged) { + if (r !== undefined) merged.push(clone(r)); + } else { + // Both changed at this index + if (deepEqual(l, r)) { + if (l !== undefined) merged.push(clone(l)); + } else { + if (l !== undefined) merged.push(clone(l)); + hasConflict = true; + } + } + } + + if (hasConflict) { + conflicts.push({ + id: `${profileId}.${fieldName}`, + path: `identity.profiles[${profileId}].${fieldName}`, + localValue: clone(local), + remoteValue: clone(remote), + ancestorValue: clone(ancestor), + }); + } + + return merged; + }, + + /** + * Apply a user's conflict resolution choice to the merged data. + * + * Navigates the `path` in the merged object and replaces the value + * with either the local or remote version from the conflict record. + * + * @param {{ identity: object, mappings: Array, settings: object }} merged + * @param {{ id: string, path: string, localValue: *, remoteValue: * }} conflict + * @param {'local'|'remote'} choice + * @returns {{ identity: object, mappings: Array, settings: object }} the mutated merged object + */ + resolveConflict(merged, conflict, choice) { + const value = choice === 'remote' ? conflict.remoteValue : conflict.localValue; + const path = conflict.path; + + // Handle mapping conflicts: mappings[] + const mappingMatch = path.match(/^mappings\[(.+)]$/); + if (mappingMatch) { + const id = mappingMatch[1]; + if (value === null) { + // Choice is to delete + merged.mappings = merged.mappings.filter((m) => m.id !== id); + } else { + const idx = merged.mappings.findIndex((m) => m.id === id); + if (idx !== -1) { + merged.mappings[idx] = clone(value); + } else { + merged.mappings.push(clone(value)); + } + } + return merged; + } + + // Handle settings conflicts: settings. + const settingsMatch = path.match(/^settings\.(.+)$/); + if (settingsMatch) { + const key = settingsMatch[1]; + merged.settings[key] = clone(value); + return merged; + } + + // Handle identity profile-level conflicts: identity.profiles[] + const profileMatch = path.match(/^identity\.profiles\[(.+)]$/); + if (profileMatch) { + const id = profileMatch[1]; + if (value === null) { + merged.identity.profiles = merged.identity.profiles.filter((p) => p.id !== id); + } else { + const idx = merged.identity.profiles.findIndex((p) => p.id === id); + if (idx !== -1) { + merged.identity.profiles[idx] = clone(value); + } else { + merged.identity.profiles.push(clone(value)); + } + } + return merged; + } + + // Handle profile field conflicts: identity.profiles[]. + const fieldMatch = path.match(/^identity\.profiles\[(.+?)]\.(.+)$/); + if (fieldMatch) { + const id = fieldMatch[1]; + const fieldPath = fieldMatch[2]; + const profile = merged.identity.profiles.find((p) => p.id === id); + if (profile) { + // Handle nested paths like "enabled.emails" + const parts = fieldPath.split('.'); + let target = profile; + for (let i = 0; i < parts.length - 1; i++) { + target = target[parts[i]]; + if (!target) break; + } + if (target) { + target[parts[parts.length - 1]] = clone(value); + } + } + return merged; + } + + return merged; + }, +}; + +export default SilentSendMerge; diff --git a/src/lib/org-policy.js b/src/lib/org-policy.js new file mode 100644 index 0000000..a4efa70 --- /dev/null +++ b/src/lib/org-policy.js @@ -0,0 +1,305 @@ +/** + * Silent Send - Organization Policy Manager + * + * Enables team/org administrators to enforce substitution policies + * across all team members. Org-required mappings merge with personal + * mappings and cannot be disabled by the user. + * + * Policy distribution: + * - Admin hosts a JSON policy file at a URL (static host, S3, etc.) + * - Team members join by entering the policy URL or an invite code + * - Extension polls the policy URL periodically (default: hourly) + * - Policy updates are applied automatically + * + * Privacy: the org admin can check compliance (are required fields + * configured?) but CANNOT see individual PPI values. + */ + +import api from './browser-polyfill.js'; + +const ORG_CONFIG_KEY = 'ss_org_config'; +const ORG_POLICY_KEY = 'ss_org_policy'; + +const OrgPolicy = { + // ---------------------------------------------------------------- + // Join / Leave + // ---------------------------------------------------------------- + + /** + * Join an organization by policy URL or invite code. + * Fetches the policy, validates it, and stores locally. + * + * @param {{ policyUrl?: string, inviteCode?: string }} + * @returns {{ success: boolean, orgName?: string, reason?: string }} + */ + async joinOrg({ policyUrl, inviteCode }) { + if (!policyUrl && !inviteCode) { + return { success: false, reason: 'Provide a policy URL or invite code.' }; + } + + // If invite code provided, it encodes the policy URL + // Format: base64(JSON({ url: '...', orgId: '...' })) + if (inviteCode && !policyUrl) { + try { + const decoded = JSON.parse(atob(inviteCode.trim())); + policyUrl = decoded.url; + } catch { + return { success: false, reason: 'Invalid invite code.' }; + } + } + + // Fetch and validate the policy + try { + const resp = await fetch(policyUrl); + if (!resp.ok) { + return { success: false, reason: `Failed to fetch policy: HTTP ${resp.status}` }; + } + + const policy = await resp.json(); + + if (!policy.orgId || !policy.orgName) { + return { success: false, reason: 'Invalid policy — missing orgId or orgName.' }; + } + + // Validate invite code if the policy requires one + if (policy.inviteCode && inviteCode) { + try { + const decoded = JSON.parse(atob(inviteCode.trim())); + if (decoded.orgId !== policy.orgId) { + return { success: false, reason: 'Invite code does not match this organization.' }; + } + } catch { /* non-fatal — URL join doesn't need code */ } + } + + // Store org config + await api.storage.local.set({ + [ORG_CONFIG_KEY]: { + policyUrl, + orgId: policy.orgId, + orgName: policy.orgName, + joinedAt: Date.now(), + lastFetch: Date.now(), + }, + [ORG_POLICY_KEY]: policy, + }); + + return { success: true, orgName: policy.orgName }; + } catch (e) { + return { success: false, reason: 'Failed to fetch policy: ' + e.message }; + } + }, + + /** + * Leave the organization. Removes org config and policy. + * May require admin password if tamper protection is enabled. + */ + async leaveOrg() { + await api.storage.local.remove([ORG_CONFIG_KEY, ORG_POLICY_KEY]); + }, + + /** + * Check if user is in an organization. + */ + async isInOrg() { + const result = await api.storage.local.get(ORG_CONFIG_KEY); + return !!result[ORG_CONFIG_KEY]?.orgId; + }, + + /** + * Get the current org config (non-sensitive metadata). + */ + async getOrgConfig() { + const result = await api.storage.local.get(ORG_CONFIG_KEY); + return result[ORG_CONFIG_KEY] || null; + }, + + // ---------------------------------------------------------------- + // Policy fetch and update + // ---------------------------------------------------------------- + + /** + * Fetch the latest policy from the org's URL. + * Only applies if the version is newer than what we have. + * + * @returns {{ updated: boolean, reason?: string }} + */ + async fetchPolicy() { + const config = await this.getOrgConfig(); + if (!config?.policyUrl) return { updated: false, reason: 'No org configured.' }; + + try { + const resp = await fetch(config.policyUrl); + if (!resp.ok) return { updated: false, reason: `HTTP ${resp.status}` }; + + const policy = await resp.json(); + const current = await this.getPolicy(); + + // Only update if version is newer + if (current && policy.version <= (current.version || 0)) { + // Update lastFetch timestamp + config.lastFetch = Date.now(); + await api.storage.local.set({ [ORG_CONFIG_KEY]: config }); + return { updated: false }; + } + + // Store updated policy + await api.storage.local.set({ + [ORG_POLICY_KEY]: policy, + [ORG_CONFIG_KEY]: { ...config, lastFetch: Date.now() }, + }); + + return { updated: true, version: policy.version }; + } catch (e) { + return { updated: false, reason: e.message }; + } + }, + + /** + * Get the cached policy. + */ + async getPolicy() { + const result = await api.storage.local.get(ORG_POLICY_KEY); + return result[ORG_POLICY_KEY] || null; + }, + + // ---------------------------------------------------------------- + // Policy enforcement — merge org rules with personal data + // ---------------------------------------------------------------- + + /** + * Merge org-required mappings with personal mappings. + * Org mappings are always included and cannot be disabled. + * + * @param {Array} personalMappings - user's own mappings + * @returns {Array} merged mappings (org + personal) + */ + async getMergedMappings(personalMappings) { + const policy = await this.getPolicy(); + if (!policy?.requiredMappings?.length) return personalMappings; + + const orgMappings = policy.requiredMappings.map(m => ({ + id: `org-${policy.orgId}-${m.real}`, + real: m.real, + substitute: m.substitute, + category: m.category || 'org', + caseSensitive: m.caseSensitive ?? false, + enabled: true, // always enabled — cannot be disabled + _orgRequired: true, + _orgId: policy.orgId, + })); + + // Remove personal mappings that conflict with org mappings (same real value) + const orgReals = new Set(orgMappings.map(m => m.real.toLowerCase())); + const filtered = personalMappings.filter( + m => !orgReals.has(m.real.toLowerCase()) + ); + + return [...orgMappings, ...filtered]; + }, + + /** + * Get org-required secret scanner patterns. + * + * @returns {Array} additional patterns to add to the secret scanner + */ + async getOrgSecretPatterns() { + const policy = await this.getPolicy(); + if (!policy?.requiredSecretPatterns?.length) return []; + + return policy.requiredSecretPatterns.map(p => ({ + name: p.name, + re: new RegExp(p.regex, 'g'), + to: p.redact || '[REDACTED]', + _orgRequired: true, + })); + }, + + // ---------------------------------------------------------------- + // Compliance checking (anonymized) + // ---------------------------------------------------------------- + + /** + * Check if the user's configuration meets org policy requirements. + * Returns compliance status WITHOUT revealing actual PPI values. + * + * @returns {{ compliant: boolean, missing: string[], configured: string[] }} + */ + async checkCompliance() { + const policy = await this.getPolicy(); + if (!policy) return { compliant: true, missing: [], configured: [] }; + + const result = await api.storage.local.get('ss_identity'); + const identity = result.ss_identity || {}; + const profiles = identity.profiles || []; + const active = profiles.filter(p => p.active); + + const missing = []; + const configured = []; + + const rules = policy.sharedIdentityRules || {}; + + // Check required categories + if (rules.requiredCategories) { + for (const cat of rules.requiredCategories) { + let found = false; + for (const p of active) { + switch (cat) { + case 'name': + if ((p.names || []).some(n => n.real && n.substitute)) found = true; + break; + case 'email': + if ((p.emails || []).some(e => e.real && e.substitute) || p.catchAllEmail) found = true; + break; + case 'username': + if ((p.usernames || []).some(u => u.real && u.substitute)) found = true; + break; + case 'hostname': + if ((p.hostnames || []).some(h => h.real && h.substitute)) found = true; + break; + case 'phone': + if ((p.phones || []).some(ph => ph.real && ph.substitute)) found = true; + break; + case 'domain': + found = true; // org-required mappings handle this + break; + } + } + if (found) { + configured.push(cat); + } else { + missing.push(cat); + } + } + } + + // Check catch-all email requirement + if (rules.requireCatchAllEmail) { + const hasCatchAll = active.some(p => !!p.catchAllEmail); + if (hasCatchAll) { + configured.push('catch-all email'); + } else { + missing.push('catch-all email'); + } + } + + return { + compliant: missing.length === 0, + missing, + configured, + }; + }, + + // ---------------------------------------------------------------- + // Invite code generation (for admins) + // ---------------------------------------------------------------- + + /** + * Generate an invite code from a policy URL and org ID. + * This is a simple base64 encoding — not a secret. + */ + generateInviteCode(policyUrl, orgId) { + return btoa(JSON.stringify({ url: policyUrl, orgId })); + }, +}; + +export default OrgPolicy; diff --git a/src/lib/sync.js b/src/lib/sync.js index 46b00ac..80c743a 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -874,6 +874,165 @@ const SilentSendSync = { }); } catch { /* ignore */ } }, + + // ---------------------------------------------------------------- + // Auto Sync — background polling for Gist/URL + // + // Config stored in ss_auto_sync_config (encrypted at rest). + // Uses chrome.alarms API for MV3-safe periodic polling. + // ---------------------------------------------------------------- + + async getAutoSyncConfig() { + const result = await api.storage.local.get('ss_auto_sync_config'); + return result.ss_auto_sync_config || null; + }, + + async saveAutoSyncConfig(config) { + await api.storage.local.set({ ss_auto_sync_config: config }); + }, + + /** + * Perform an auto-sync cycle: pull from remote, push if local changed. + * Called by the alarms listener in the service worker. + * + * @returns {{ pulled: boolean, pushed: boolean, error?: string }} + */ + async performAutoSync() { + const config = await this.getAutoSyncConfig(); + if (!config?.enabled) return { pulled: false, pushed: false }; + + let pulled = false; + let pushed = false; + let error = null; + + try { + if (config.method === 'gist' && config.gistToken) { + // Pull from Gist + const pullResult = await this.pullFromGist(config.gistToken); + if (pullResult.success && pullResult.imported) pulled = true; + + // Push if local data changed since last push + const local = await this._getAllData(); + if (!config.lastPush || local.lastModified > config.lastPush) { + const pushResult = await this.pushToGist(config.gistToken); + if (pushResult.success) { + pushed = true; + config.lastPush = Date.now(); + } + } + } else if (config.method === 'url' && config.url) { + // Pull from URL + const headers = config.headers || {}; + const pullResult = await this.pullFromUrl({ url: config.url, headers }); + if (pullResult.success && pullResult.imported) pulled = true; + + // Push if local data changed since last push + const local = await this._getAllData(); + if (!config.lastPush || local.lastModified > config.lastPush) { + const pushResult = await this.pushToUrl({ + url: config.url, + method: config.httpMethod || 'PUT', + headers, + }); + if (pushResult.success) { + pushed = true; + config.lastPush = Date.now(); + } + } + } + + // Update timestamps + config.lastPull = Date.now(); + await this.saveAutoSyncConfig(config); + } catch (e) { + error = e.message; + } + + return { pulled, pushed, error }; + }, + + // ---------------------------------------------------------------- + // Multi-Device Dashboard + // + // Each device registers itself with a unique ID + name. + // Device list is embedded in the sync data so all devices + // see each other. + // ---------------------------------------------------------------- + + /** + * Get or create device info for this device. + */ + async getDeviceInfo() { + const result = await api.storage.local.get('ss_device_info'); + if (result.ss_device_info) return result.ss_device_info; + + // Auto-detect device name + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + let browser = 'Unknown'; + if (ua.includes('Firefox')) browser = 'Firefox'; + else if (ua.includes('Chrome')) browser = 'Chrome'; + else if (ua.includes('Safari')) browser = 'Safari'; + else if (ua.includes('Edge')) browser = 'Edge'; + + const platform = typeof navigator !== 'undefined' + ? (navigator.platform || navigator.userAgentData?.platform || 'Unknown') + : 'Unknown'; + + const info = { + id: crypto.randomUUID(), + name: `${browser} on ${platform}`, + browser, + platform, + createdAt: Date.now(), + lastSync: Date.now(), + }; + + await api.storage.local.set({ ss_device_info: info }); + return info; + }, + + /** + * Rename this device. + */ + async setDeviceName(name) { + const info = await this.getDeviceInfo(); + info.name = name; + await api.storage.local.set({ ss_device_info: info }); + }, + + /** + * Get the list of all known devices. + */ + async getDevices() { + const result = await api.storage.local.get('ss_devices'); + return result.ss_devices || {}; + }, + + /** + * Remove a device from the tracked list. + */ + async removeDevice(deviceId) { + const devices = await this.getDevices(); + delete devices[deviceId]; + await api.storage.local.set({ ss_devices: devices }); + }, + + // Override _getAllData to include device info and device list + async _getAllDataWithDevices() { + const data = await this._getAllData(); + const deviceInfo = await this.getDeviceInfo(); + const devices = await this.getDevices(); + + // Register/update this device in the list + devices[deviceInfo.id] = { + ...deviceInfo, + lastSync: Date.now(), + }; + await api.storage.local.set({ ss_devices: devices }); + + data.devices = devices; + return data; + }, }; export default SilentSendSync; diff --git a/src/lib/tamper-guard.js b/src/lib/tamper-guard.js new file mode 100644 index 0000000..34f50cb --- /dev/null +++ b/src/lib/tamper-guard.js @@ -0,0 +1,246 @@ +/** + * Silent Send - Tamper Protection + * + * Prevents unauthorized disabling, data clearing, or org policy + * removal by requiring a separate admin password. This is distinct + * from the vault encryption password. + * + * Protected actions: + * - Disabling the extension (toggle off) + * - Clearing all data (reset) + * - Clearing mappings + * - Changing or removing org policy URL + * - Exporting data (plain, unencrypted) + * + * Limitations (documented in UI): + * - Cannot prevent browser-level extension uninstall + * - Cannot prevent clearing browser data via browser settings + * - A determined user with dev tools can bypass this + * - This is a deterrent for casual tampering, not a security boundary + * + * Org integration: + * - If org policy sets disableTamperProtection: false, the tamper + * guard cannot be turned off even with the admin password + * - If org policy sets disableTamperProtection: true, the org + * admin is opting out of this feature + */ + +import api from './browser-polyfill.js'; + +const ADMIN_AUTH_KEY = 'ss_admin_auth'; +const ITERATIONS = 200000; // higher than sync encryption for extra security +const SALT_LENGTH = 16; + +const TamperGuard = { + /** + * Check if tamper protection is enabled. + */ + async isEnabled() { + const result = await api.storage.local.get(ADMIN_AUTH_KEY); + return !!result[ADMIN_AUTH_KEY]?.enabled; + }, + + /** + * Get the tamper guard config (without the password hash). + */ + async getConfig() { + const result = await api.storage.local.get(ADMIN_AUTH_KEY); + const config = result[ADMIN_AUTH_KEY]; + if (!config) return null; + // Don't expose the hash + return { + enabled: config.enabled, + protectedActions: config.protectedActions, + orgCanDisable: config.orgCanDisable, + createdAt: config.createdAt, + }; + }, + + /** + * Set up tamper protection with an admin password. + * + * @param {string} adminPassword - must be different from encryption password + * @returns {{ success: boolean, reason?: string }} + */ + async setup(adminPassword) { + if (!adminPassword || adminPassword.length < 4) { + return { success: false, reason: 'Admin password must be at least 4 characters.' }; + } + + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const hash = await this._hashPassword(adminPassword, salt); + + const config = { + enabled: true, + passwordHash: hash, + salt: btoa(String.fromCharCode(...salt)), + protectedActions: [ + 'disable', + 'clearData', + 'clearMappings', + 'changeOrgPolicy', + 'exportPlain', + ], + orgCanDisable: false, + createdAt: Date.now(), + }; + + await api.storage.local.set({ [ADMIN_AUTH_KEY]: config }); + return { success: true }; + }, + + /** + * Verify the admin password. + * + * @param {string} adminPassword + * @returns {boolean} + */ + async verify(adminPassword) { + const result = await api.storage.local.get(ADMIN_AUTH_KEY); + const config = result[ADMIN_AUTH_KEY]; + if (!config?.enabled) return true; // not enabled = always passes + + const salt = Uint8Array.from(atob(config.salt), c => c.charCodeAt(0)); + const hash = await this._hashPassword(adminPassword, salt); + + return hash === config.passwordHash; + }, + + /** + * Check if a specific action requires admin authentication. + * + * @param {string} action - e.g. 'disable', 'clearData' + * @returns {boolean} + */ + async isActionProtected(action) { + const result = await api.storage.local.get(ADMIN_AUTH_KEY); + const config = result[ADMIN_AUTH_KEY]; + if (!config?.enabled) return false; + return (config.protectedActions || []).includes(action); + }, + + /** + * Require admin authentication for an action. + * Returns true if auth passes (or isn't needed), false if denied. + * + * @param {string} action + * @param {string} adminPassword + * @returns {{ allowed: boolean, reason?: string }} + */ + async requireAuth(action, adminPassword) { + const isProtected = await this.isActionProtected(action); + if (!isProtected) return { allowed: true }; + + if (!adminPassword) { + return { allowed: false, reason: 'Admin password required.' }; + } + + const valid = await this.verify(adminPassword); + if (!valid) { + return { allowed: false, reason: 'Wrong admin password.' }; + } + + return { allowed: true }; + }, + + /** + * Disable tamper protection. + * Requires the admin password unless org policy allows disabling. + * + * @param {string} adminPassword + * @returns {{ success: boolean, reason?: string }} + */ + async disable(adminPassword) { + // Check org policy + const orgPolicy = await this._getOrgPolicy(); + if (orgPolicy && orgPolicy.disableTamperProtection === false) { + return { + success: false, + reason: 'Organization policy prevents disabling tamper protection.', + }; + } + + // Verify password + const valid = await this.verify(adminPassword); + if (!valid) { + return { success: false, reason: 'Wrong admin password.' }; + } + + await api.storage.local.remove(ADMIN_AUTH_KEY); + return { success: true }; + }, + + /** + * Change the admin password. + * + * @param {string} oldPassword + * @param {string} newPassword + * @returns {{ success: boolean, reason?: string }} + */ + async changePassword(oldPassword, newPassword) { + const valid = await this.verify(oldPassword); + if (!valid) { + return { success: false, reason: 'Wrong current admin password.' }; + } + + if (!newPassword || newPassword.length < 4) { + return { success: false, reason: 'New password must be at least 4 characters.' }; + } + + const result = await api.storage.local.get(ADMIN_AUTH_KEY); + const config = result[ADMIN_AUTH_KEY]; + + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + config.passwordHash = await this._hashPassword(newPassword, salt); + config.salt = btoa(String.fromCharCode(...salt)); + + await api.storage.local.set({ [ADMIN_AUTH_KEY]: config }); + return { success: true }; + }, + + // ---------------------------------------------------------------- + // Internal helpers + // ---------------------------------------------------------------- + + /** + * Hash a password with PBKDF2-SHA256. + * Returns base64-encoded hash string. + */ + async _hashPassword(password, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ); + + const bits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations: ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + 256 + ); + + return btoa(String.fromCharCode(...new Uint8Array(bits))); + }, + + /** + * Get org policy (if any) to check tamper protection override. + */ + async _getOrgPolicy() { + try { + const result = await api.storage.local.get('ss_org_policy'); + return result.ss_org_policy || null; + } catch { + return null; + } + }, +}; + +export default TamperGuard; diff --git a/src/lib/version-history.js b/src/lib/version-history.js new file mode 100644 index 0000000..c8abf4e --- /dev/null +++ b/src/lib/version-history.js @@ -0,0 +1,207 @@ +/** + * Silent Send - Version History Manager + * + * Manages sync version history using IndexedDB. Stores snapshots of the + * extension's data (identity, mappings, settings) so the user can + * rollback to a previous state after a sync overwrites local data. + * + * Each snapshot records the source that triggered it (e.g. 'gist', + * 'browser-sync', 'rollback') and a full copy of the data at that + * point in time. Old snapshots are automatically pruned to stay within + * a configurable maximum (default 10). + * + * Database: IndexedDB 'ss_version_history', version 1 + * Object store: 'snapshots' (autoIncrement keyPath 'id', index on 'timestamp') + */ + +const DB_NAME = 'ss_version_history'; +const DB_VERSION = 1; +const STORE_NAME = 'snapshots'; +const DEFAULT_MAX_SNAPSHOTS = 10; + +const VersionHistory = { + // ---------------------------------------------------------------- + // Internal helpers + // ---------------------------------------------------------------- + + /** + * Open (or create/upgrade) the IndexedDB database. + * @returns {Promise} + */ + async _openDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { + keyPath: 'id', + autoIncrement: true, + }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + }, + + // ---------------------------------------------------------------- + // Public API + // ---------------------------------------------------------------- + + /** + * Save a snapshot of the current extension data. + * + * @param {Object} data - The data payload to snapshot. + * @param {string} data.version - Data schema version. + * @param {number} data.lastModified - Epoch ms of the data. + * @param {Object} data.identity - Identity fields. + * @param {Array} data.mappings - Substitution mappings. + * @param {Object} data.settings - Extension settings. + * @param {string} source - Origin of the snapshot, e.g. + * 'local'|'gist'|'url'|'browser-sync'|'file'|'code'|'rollback'. + * @returns {Promise} The auto-generated snapshot id. + */ + async saveSnapshot(data, source) { + const db = await this._openDB(); + const snapshot = { + timestamp: Date.now(), + source, + data, + }; + + const id = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.add(snapshot); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + db.close(); + + // Prune old snapshots beyond the configured maximum + const maxVersions = data?.settings?.maxVersionHistory ?? DEFAULT_MAX_SNAPSHOTS; + await this.pruneToMax(maxVersions); + + return id; + }, + + /** + * Retrieve all snapshots, sorted newest first. + * @returns {Promise} Array of snapshot objects. + */ + async getSnapshots() { + const db = await this._openDB(); + const snapshots = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + db.close(); + + // Sort newest first by timestamp (fallback to id descending) + snapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id); + return snapshots; + }, + + /** + * Retrieve a single snapshot by id. + * @param {number} id - The snapshot id. + * @returns {Promise} The snapshot, or undefined. + */ + async getSnapshot(id) { + const db = await this._openDB(); + const snapshot = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.get(id); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + db.close(); + return snapshot; + }, + + /** + * Delete a single snapshot by id. + * @param {number} id - The snapshot id to remove. + * @returns {Promise} + */ + async deleteSnapshot(id) { + const db = await this._openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + db.close(); + }, + + /** + * Keep only the N newest snapshots, deleting the rest. + * @param {number} [maxVersions] - Maximum snapshots to retain + * (defaults to DEFAULT_MAX_SNAPSHOTS). + * @returns {Promise} + */ + async pruneToMax(maxVersions) { + const max = maxVersions ?? DEFAULT_MAX_SNAPSHOTS; + if (max < 1) return; + + const db = await this._openDB(); + const allSnapshots = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + // Sort newest first, then mark everything beyond `max` for deletion + allSnapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id); + const toDelete = allSnapshots.slice(max); + + if (toDelete.length > 0) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const snap of toDelete) { + store.delete(snap.id); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + db.close(); + }, + + /** + * Delete all snapshots (wipe version history). + * @returns {Promise} + */ + async clearAll() { + const db = await this._openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + db.close(); + }, +}; + +export default VersionHistory; 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;