diff --git a/manifest.firefox.json b/manifest.firefox.json index b20038a..38fd0ba 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..095fc5e 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(); @@ -398,11 +404,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 +507,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;