feat: auto-sync, version history, merge, org policy, tamper guard

New modules:
- version-history.js: IndexedDB snapshot storage with pruning/rollback
- merge.js: three-way field-level merge for sync conflict resolution
- org-policy.js: team/org policy enforcement, compliance checking,
  invite codes, required mappings that can't be disabled
- tamper-guard.js: admin password to protect disable/clear/export
  actions, org policy can prevent disabling

Auto background sync:
- Gist/URL sync via chrome.alarms (MV3-safe, survives SW restarts)
- Configurable interval (5/15/30 min), push on local changes
- performAutoSync() orchestrates pull-then-conditional-push

Multi-device dashboard:
- Device auto-registration with UUID + browser/platform detection
- Device list embedded in sync data for cross-device visibility
- getDeviceInfo(), setDeviceName(), getDevices(), removeDevice()

Manifest changes:
- Added "alarms" permission for background polling

Service worker:
- Alarm listeners for auto-sync and org policy polling (hourly)
- Tamper guard message handlers
- Alarms set up on install and startup

WIP: Options UI integration pending for all new features.

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 19:39:28 +00:00
parent d98440350b
commit 70c7b759c6
8 changed files with 1708 additions and 2 deletions
+704
View File
@@ -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[<id>]
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.<key>
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[<id>]
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[<id>].<field>
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;
+305
View File
@@ -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;
+159
View File
@@ -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;
+246
View File
@@ -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;
+207
View File
@@ -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<IDBDatabase>}
*/
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<number>} 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>} 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<Object|undefined>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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;