Merge pull request #9 from outis1one/claude/read-repo-wA3y1

feat: auto-sync, version history, merge, org policy, tamper guard
This commit is contained in:
Outis
2026-03-26 15:46:05 -04:00
committed by GitHub
11 changed files with 2309 additions and 2 deletions
+9
View File
@@ -322,6 +322,15 @@ All sync channels (browser sync, GitHub Gist, folder sync, custom URL, sync code
3. Full configuration (including TOTP secret) is restored from the encrypted payload
4. WebAuthn credential is registered locally for future re-verification
### Managed browser deployments
For organizations that want to prevent extension removal:
- **Chrome / Chromium / Edge:** Use the `ExtensionInstallForcelist` group policy. See [Chrome Enterprise policies](https://chromeenterprise.google/policies/#ExtensionInstallForcelist).
- **Firefox:** Use the `ExtensionSettings` policy in `policies.json` or via Group Policy. See [Firefox Enterprise policies](https://mozilla.github.io/policy-templates/#extensionsettings).
These are standard browser management features — Silent Send does not attempt to prevent its own removal.
### Smart reveal
Reveal mode only replaces values that were **actually substituted** in outbound messages during the current session. If the AI uses a word that happens to match one of your substitute values (e.g., the AI says "the user should..." and "user" is a configured substitute), it won't be falsely revealed as your real username.
+2 -1
View File
@@ -13,7 +13,8 @@
"storage",
"activeTab",
"scripting",
"notifications"
"notifications",
"alarms"
],
"host_permissions": [
"https://claude.ai/*",
+2 -1
View File
@@ -7,7 +7,8 @@
"storage",
"activeTab",
"scripting",
"notifications"
"notifications",
"alarms"
],
"host_permissions": [
"https://claude.ai/*",
+91
View File
@@ -8,8 +8,14 @@
import Storage from '../lib/storage.js';
import SilentSendSync from '../lib/sync.js';
import OrgPolicy from '../lib/org-policy.js';
import TamperGuard from '../lib/tamper-guard.js';
import api from '../lib/browser-polyfill.js';
// --- Alarm names ---
const AUTO_SYNC_ALARM = 'ss-auto-sync';
const ORG_POLICY_ALARM = 'ss-org-policy';
// Track substitution counts per tab
const tabCounts = new Map();
@@ -153,6 +159,14 @@ const messageHandlers = {
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
},
async 'autosync:config-changed'() {
await setupAutoSyncAlarm();
},
async 'org:config-changed'() {
await setupOrgPolicyAlarm();
},
async 'get:locked-state'(_message, _sender, sendResponse) {
const locked = await Storage.isLocked();
sendResponse({ locked });
@@ -398,11 +412,84 @@ api.notifications.onClicked.addListener((notificationId) => {
}
});
// --- Alarms — background polling (MV3-safe, survives service worker restarts) ---
async function setupAutoSyncAlarm() {
const config = await SilentSendSync.getAutoSyncConfig();
if (config?.enabled) {
api.alarms.create(AUTO_SYNC_ALARM, {
periodInMinutes: config.interval || 15,
});
} else {
api.alarms.clear(AUTO_SYNC_ALARM).catch(() => {});
}
}
async function setupOrgPolicyAlarm() {
const inOrg = await OrgPolicy.isInOrg();
if (inOrg) {
api.alarms.create(ORG_POLICY_ALARM, { periodInMinutes: 60 });
} else {
api.alarms.clear(ORG_POLICY_ALARM).catch(() => {});
}
}
api.alarms.onAlarm.addListener(async (alarm) => {
// Skip if locked
const locked = await Storage.isLocked();
if (locked) return;
if (alarm.name === AUTO_SYNC_ALARM) {
const result = await SilentSendSync.performAutoSync();
if (result.pulled) {
console.log('[Silent Send] Auto-sync pulled new data');
}
if (result.error) {
console.warn('[Silent Send] Auto-sync error:', result.error);
}
}
if (alarm.name === ORG_POLICY_ALARM) {
const result = await OrgPolicy.fetchPolicy();
if (result.updated) {
console.log('[Silent Send] Org policy updated to version', result.version);
// Notify content scripts of potential new mappings
broadcastSettings(await Storage.getSettings());
}
}
});
// --- Tamper guard message handlers ---
const tamperHandlers = {
async 'tamper:check-action'(message, _sender, sendResponse) {
const result = await TamperGuard.requireAuth(message.action, message.adminPassword);
sendResponse(result);
},
async 'tamper:is-enabled'(_message, _sender, sendResponse) {
const enabled = await TamperGuard.isEnabled();
sendResponse({ enabled });
},
};
// Add tamper handlers to main message handler
const origHandler = api.runtime.onMessage._listeners?.[0];
api.runtime.onMessage.addListener((message, sender, sendResponse) => {
const handler = tamperHandlers[message.type];
if (handler) {
handler(message, sender, sendResponse);
return true;
}
});
// --- Set initial state ---
api.runtime.onInstalled.addListener(async () => {
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
const settings = await Storage.getSettings();
await updateIcon(settings);
// Set up alarms on install
await setupAutoSyncAlarm();
await setupOrgPolicyAlarm();
});
// Also set icon on startup (service worker wake) + pull any newer sync data
@@ -428,4 +515,8 @@ api.runtime.onInstalled.addListener(async () => {
if (settings.browserSync) {
await SilentSendSync.pullFromSyncStorage();
}
// Set up periodic alarms
await setupAutoSyncAlarm();
await setupOrgPolicyAlarm();
})();
+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;
+132
View File
@@ -276,6 +276,34 @@
<div id="gistSyncStatus" style="font-size:12px;min-height:16px"></div>
</div>
<!-- Auto Sync -->
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Auto Sync — background polling</h3>
<p class="section-desc" style="margin-bottom:8px">
Automatically push and pull settings on an interval. Uses the Gist or URL method configured above.
</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
<label class="toggle" title="Enable auto sync">
<input type="checkbox" id="autoSyncEnabled">
<span class="toggle-slider"></span>
</label>
<select id="autoSyncMethod" style="font-size:12px;padding:3px 6px;border:1px solid #d1d5db;border-radius:4px">
<option value="gist">GitHub Gist</option>
<option value="url">Custom URL</option>
</select>
<div style="display:flex;align-items:center;gap:4px">
<label style="font-size:12px;white-space:nowrap">Every</label>
<select id="autoSyncInterval" style="font-size:12px;padding:3px 6px;border:1px solid #d1d5db;border-radius:4px">
<option value="5">5 min</option>
<option value="15" selected>15 min</option>
<option value="30">30 min</option>
<option value="60">1 hour</option>
</select>
</div>
</div>
<div id="autoSyncStatus" style="font-size:12px;min-height:16px"></div>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Custom URL Sync</h3>
<p class="section-desc" style="margin-bottom:8px">
@@ -297,6 +325,70 @@
</div>
</section>
<!-- Sync Conflicts -->
<section class="section" id="conflictSection" style="display:none">
<h2 style="color:#b45309">Sync Conflicts</h2>
<p class="section-desc">Both this device and another device changed the same data. Choose which version to keep for each conflict.</p>
<div id="conflictList"></div>
</section>
<!-- Connected Devices -->
<section class="section">
<h2>Connected Devices</h2>
<p class="section-desc">Devices that sync with this extension. The device list is shared via sync data.</p>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
<label style="font-size:12px;white-space:nowrap">This device:</label>
<input type="text" id="deviceName" placeholder="Device name" style="flex:1;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-sm" id="btnRenameDevice">Rename</button>
</div>
<div id="deviceList" style="font-size:13px"></div>
</section>
<!-- Version History -->
<section class="section">
<h2>Version History</h2>
<p class="section-desc">Snapshots of your data are saved automatically on each sync. Restore a previous version if a sync overwrote something important.</p>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
<label style="font-size:12px;white-space:nowrap">Max snapshots</label>
<input type="number" id="maxVersionHistory" class="input-small" min="3" max="50" value="10">
<button class="btn btn-sm btn-danger" id="btnClearVersionHistory">Clear History</button>
</div>
<div id="versionHistoryList" style="font-size:13px"></div>
</section>
<!-- Organization -->
<section class="section">
<h2>Organization</h2>
<p class="section-desc">Join an organization to receive required substitution rules and secret scanner patterns from your admin. Org rules merge with your personal rules and cannot be disabled.</p>
<div id="orgNotJoined">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="text" id="orgInviteCode" placeholder="Invite code" style="flex:1;min-width:140px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;font-family:monospace">
<button class="btn btn-primary" id="btnJoinOrgCode">Join</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="url" id="orgPolicyUrl" placeholder="Policy URL (https://…/policy.json)" style="flex:1;min-width:200px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn" id="btnJoinOrgUrl">Join by URL</button>
</div>
</div>
<div id="orgJoined" style="display:none">
<div style="padding:10px;background:#f0fdf4;border:1px solid #86efac;border-radius:6px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<div>
<strong id="orgNameDisplay" style="font-size:13px"></strong>
<span id="orgPolicyVersion" style="font-size:11px;color:#6b7280;margin-left:6px"></span>
</div>
<button class="btn btn-sm btn-danger" id="btnLeaveOrg">Leave</button>
</div>
<div id="orgComplianceStatus" style="font-size:12px;margin-top:6px"></div>
<div id="orgRequiredMappings" style="font-size:11px;color:#6b7280;margin-top:4px"></div>
</div>
</div>
<div id="orgStatus" style="font-size:12px;min-height:16px"></div>
</section>
<section class="section">
<h2>Transfer Data</h2>
<p class="section-desc">Export all your identities, mappings, and settings to a file. Encrypted exports require a password to decrypt.</p>
@@ -379,6 +471,46 @@
<section class="section">
<h2>Danger Zone</h2>
<!-- Tamper Protection -->
<div style="margin-bottom:16px;padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px">
<h3 style="font-size:13px;font-weight:600;margin:0 0 6px">Tamper Protection</h3>
<p class="section-desc" style="margin-bottom:8px">
Require a separate admin password to disable the extension, clear data, or change org settings.
This is a deterrent — it cannot prevent browser-level uninstall or dev tools access.
</p>
<div id="tamperNotEnabled">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<input type="password" id="tamperAdminPassword" placeholder="Admin password" autocomplete="new-password"
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<input type="password" id="tamperAdminPasswordConfirm" placeholder="Confirm" autocomplete="new-password"
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-primary btn-sm" id="btnEnableTamper">Enable</button>
</div>
</div>
<div id="tamperEnabled" style="display:none">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="font-size:12px;color:#dc2626;font-weight:500">&#128274; Active</span>
<button class="btn btn-sm" id="btnChangeTamperPassword">Change Password</button>
<button class="btn btn-sm btn-danger" id="btnDisableTamper">Disable</button>
</div>
</div>
<div id="tamperStatus" style="font-size:12px;margin-top:4px;min-height:14px"></div>
</div>
<!-- Admin auth dialog (reusable prompt for protected actions) -->
<dialog id="adminAuthDialog" style="border:1px solid #d1d5db;border-radius:8px;padding:20px;max-width:300px">
<h3 style="margin:0 0 8px;font-size:14px">Admin Password Required</h3>
<p id="adminAuthReason" style="font-size:12px;color:#6b7280;margin:0 0 12px"></p>
<input type="password" id="adminAuthInput" placeholder="Admin password" autocomplete="current-password"
style="width:100%;box-sizing:border-box;font-size:13px;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:8px">
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-sm" id="btnAdminAuthCancel">Cancel</button>
<button class="btn btn-primary btn-sm" id="btnAdminAuthSubmit">Confirm</button>
</div>
<div id="adminAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px;color:#dc2626"></div>
</dialog>
<div class="setting-row">
<div>
<label>Reset all data</label>
+452
View File
@@ -1,6 +1,10 @@
import Storage from '../lib/storage.js';
import SilentSendCrypto from '../lib/crypto.js';
import SilentSendSync from '../lib/sync.js';
import VersionHistory from '../lib/version-history.js';
import SilentSendMerge from '../lib/merge.js';
import OrgPolicy from '../lib/org-policy.js';
import TamperGuard from '../lib/tamper-guard.js';
import api from '../lib/browser-polyfill.js';
let mappings = [];
@@ -25,6 +29,14 @@ document.addEventListener('DOMContentLoaded', async () => {
renderDomains();
renderLog();
// --- New features ---
await initAutoSyncUI();
await initVersionHistoryUI();
await initDeviceDashboard();
await initOrgUI();
await initTamperUI();
await checkConflicts();
// --- Sync Encryption UI ---
await initSyncEncryptionUI();
@@ -1049,6 +1061,446 @@ function setSyncAuthStatus(msg, type) {
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
// ----------------------------------------------------------------
// Auto Sync UI
// ----------------------------------------------------------------
async function initAutoSyncUI() {
const config = await SilentSendSync.getAutoSyncConfig();
if (config) {
$('#autoSyncEnabled').checked = config.enabled || false;
$('#autoSyncMethod').value = config.method || 'gist';
$('#autoSyncInterval').value = String(config.interval || 15);
if (config.lastPull) {
setAutoSyncStatus(`Last pull: ${new Date(config.lastPull).toLocaleString()}`, 'ok');
}
}
const saveAutoSync = async () => {
const config = (await SilentSendSync.getAutoSyncConfig()) || {};
config.enabled = $('#autoSyncEnabled').checked;
config.method = $('#autoSyncMethod').value;
config.interval = parseInt($('#autoSyncInterval').value, 10) || 15;
// Inherit token/URL from existing fields
if (config.method === 'gist') {
const token = $('#gistToken').value.trim();
if (token) config.gistToken = token;
} else {
config.url = $('#customSyncUrl').value.trim();
config.headers = parseHeadersField($('#customSyncHeaders').value);
}
await SilentSendSync.saveAutoSyncConfig(config);
// Tell service worker to reconfigure alarm
api.runtime.sendMessage({ type: 'autosync:config-changed' }).catch(() => {});
setAutoSyncStatus(config.enabled ? 'Auto sync enabled.' : 'Auto sync disabled.', config.enabled ? 'ok' : 'neutral');
};
$('#autoSyncEnabled').addEventListener('change', saveAutoSync);
$('#autoSyncMethod').addEventListener('change', saveAutoSync);
$('#autoSyncInterval').addEventListener('change', saveAutoSync);
}
function setAutoSyncStatus(msg, type) {
const el = $('#autoSyncStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
// ----------------------------------------------------------------
// Version History UI
// ----------------------------------------------------------------
async function initVersionHistoryUI() {
$('#maxVersionHistory').value = settings.maxVersionHistory || 10;
$('#maxVersionHistory').addEventListener('change', async (e) => {
await Storage.saveSettings({ maxVersionHistory: parseInt(e.target.value, 10) || 10 });
});
$('#btnClearVersionHistory').addEventListener('click', async () => {
if (!confirm('Clear all version history snapshots?')) return;
await VersionHistory.clearAll();
renderVersionHistory();
});
await renderVersionHistory();
}
async function renderVersionHistory() {
const list = $('#versionHistoryList');
const snapshots = await VersionHistory.getSnapshots();
if (snapshots.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px">No snapshots yet. Snapshots are created on each sync.</div>';
return;
}
list.innerHTML = snapshots.map(s => {
const time = new Date(s.timestamp).toLocaleString();
const mappingCount = (s.data?.mappings || []).length;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
<div>
<span style="font-size:12px;font-weight:500">${time}</span>
<span style="font-size:11px;color:#6b7280;margin-left:8px">via ${escapeHtml(s.source || 'unknown')}</span>
<span style="font-size:11px;color:#9ca3af;margin-left:8px">${mappingCount} mappings</span>
</div>
<button class="btn btn-sm btn-restore-snapshot" data-id="${s.id}">Restore</button>
</div>`;
}).join('');
list.querySelectorAll('.btn-restore-snapshot').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id, 10);
if (!confirm('Restore this snapshot? Current data will be overwritten.')) return;
const snapshot = await VersionHistory.getSnapshot(id);
if (snapshot?.data) {
await SilentSendSync._applyData(snapshot.data, 'rollback');
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
renderLog();
alert('Restored. Reload open AI tabs for changes to take effect.');
}
});
});
}
// ----------------------------------------------------------------
// Connected Devices UI
// ----------------------------------------------------------------
async function initDeviceDashboard() {
const deviceInfo = await SilentSendSync.getDeviceInfo();
$('#deviceName').value = deviceInfo.name;
$('#btnRenameDevice').addEventListener('click', async () => {
const name = $('#deviceName').value.trim();
if (!name) return;
await SilentSendSync.setDeviceName(name);
renderDevices();
});
await renderDevices();
}
async function renderDevices() {
const list = $('#deviceList');
const devices = await SilentSendSync.getDevices();
const currentDevice = await SilentSendSync.getDeviceInfo();
const entries = Object.values(devices);
if (entries.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px">No devices synced yet. Push or pull to register this device.</div>';
return;
}
entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0));
list.innerHTML = `<table style="width:100%;font-size:12px;border-collapse:collapse">
<thead><tr style="text-align:left;border-bottom:1px solid #e5e7eb">
<th style="padding:6px">Device</th>
<th style="padding:6px">Browser</th>
<th style="padding:6px">Last Sync</th>
<th style="padding:6px"></th>
</tr></thead>
<tbody>${entries.map(d => {
const isCurrent = d.id === currentDevice.id;
const lastSync = d.lastSync ? new Date(d.lastSync).toLocaleString() : 'Never';
return `<tr style="border-bottom:1px solid #f3f4f6">
<td style="padding:6px">${escapeHtml(d.name || 'Unknown')} ${isCurrent ? '<span style="color:#10b981;font-size:10px">(this)</span>' : ''}</td>
<td style="padding:6px">${escapeHtml(d.browser || '?')}</td>
<td style="padding:6px">${lastSync}</td>
<td style="padding:6px">${!isCurrent ? `<button class="btn btn-sm btn-danger btn-remove-device" data-id="${d.id}">&times;</button>` : ''}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
list.querySelectorAll('.btn-remove-device').forEach(btn => {
btn.addEventListener('click', async () => {
await SilentSendSync.removeDevice(btn.dataset.id);
renderDevices();
});
});
}
// ----------------------------------------------------------------
// Organization UI
// ----------------------------------------------------------------
async function initOrgUI() {
const inOrg = await OrgPolicy.isInOrg();
if (inOrg) {
await showOrgJoined();
} else {
showOrgNotJoined();
}
$('#btnJoinOrgCode').addEventListener('click', async () => {
const code = $('#orgInviteCode').value.trim();
if (!code) { setOrgStatus('Enter an invite code.', 'warn'); return; }
setOrgStatus('Joining...', 'neutral');
const result = await OrgPolicy.joinOrg({ inviteCode: code });
if (result.success) {
setOrgStatus(`Joined ${result.orgName}.`, 'ok');
await showOrgJoined();
} else {
setOrgStatus('Failed: ' + result.reason, 'error');
}
});
$('#btnJoinOrgUrl').addEventListener('click', async () => {
const url = $('#orgPolicyUrl').value.trim();
if (!url) { setOrgStatus('Enter a policy URL.', 'warn'); return; }
setOrgStatus('Joining...', 'neutral');
const result = await OrgPolicy.joinOrg({ policyUrl: url });
if (result.success) {
setOrgStatus(`Joined ${result.orgName}.`, 'ok');
await showOrgJoined();
} else {
setOrgStatus('Failed: ' + result.reason, 'error');
}
});
$('#btnLeaveOrg').addEventListener('click', async () => {
// Check tamper protection
if (await TamperGuard.isActionProtected('changeOrgPolicy')) {
const pw = await promptAdminPassword('Leave organization');
if (!pw) return;
const auth = await TamperGuard.verify(pw);
if (!auth) { setOrgStatus('Wrong admin password.', 'error'); return; }
}
if (!confirm('Leave this organization? Org-required mappings will be removed.')) return;
await OrgPolicy.leaveOrg();
showOrgNotJoined();
setOrgStatus('Left organization.', 'neutral');
});
}
async function showOrgJoined() {
$('#orgNotJoined').style.display = 'none';
$('#orgJoined').style.display = 'block';
const config = await OrgPolicy.getOrgConfig();
const policy = await OrgPolicy.getPolicy();
if (config) {
$('#orgNameDisplay').textContent = config.orgName;
$('#orgPolicyVersion').textContent = `v${policy?.version || '?'}`;
}
const compliance = await OrgPolicy.checkCompliance();
const statusEl = $('#orgComplianceStatus');
if (compliance.compliant) {
statusEl.innerHTML = '<span style="color:#10b981">&#10003; Compliant — all required fields configured</span>';
} else {
statusEl.innerHTML = `<span style="color:#b45309">Missing: ${compliance.missing.join(', ')}</span>`;
}
const reqMappings = policy?.requiredMappings || [];
const reqEl = $('#orgRequiredMappings');
if (reqMappings.length > 0) {
reqEl.textContent = `${reqMappings.length} required mapping(s) enforced by org policy`;
} else {
reqEl.textContent = '';
}
}
function showOrgNotJoined() {
$('#orgNotJoined').style.display = 'block';
$('#orgJoined').style.display = 'none';
}
function setOrgStatus(msg, type) {
const el = $('#orgStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
// ----------------------------------------------------------------
// Tamper Protection UI
// ----------------------------------------------------------------
async function initTamperUI() {
const enabled = await TamperGuard.isEnabled();
if (enabled) {
$('#tamperNotEnabled').style.display = 'none';
$('#tamperEnabled').style.display = 'block';
}
$('#btnEnableTamper').addEventListener('click', async () => {
const pw = $('#tamperAdminPassword').value;
const confirm = $('#tamperAdminPasswordConfirm').value;
if (!pw) { setTamperStatus('Enter a password.', 'warn'); return; }
if (pw !== confirm) { setTamperStatus('Passwords do not match.', 'error'); return; }
const result = await TamperGuard.setup(pw);
if (result.success) {
$('#tamperNotEnabled').style.display = 'none';
$('#tamperEnabled').style.display = 'block';
$('#tamperAdminPassword').value = '';
$('#tamperAdminPasswordConfirm').value = '';
setTamperStatus('Tamper protection enabled.', 'ok');
} else {
setTamperStatus(result.reason, 'error');
}
});
$('#btnDisableTamper').addEventListener('click', async () => {
const pw = await promptAdminPassword('Disable tamper protection');
if (!pw) return;
const result = await TamperGuard.disable(pw);
if (result.success) {
$('#tamperNotEnabled').style.display = 'block';
$('#tamperEnabled').style.display = 'none';
setTamperStatus('Tamper protection disabled.', 'neutral');
} else {
setTamperStatus(result.reason, 'error');
}
});
$('#btnChangeTamperPassword').addEventListener('click', async () => {
const oldPw = await promptAdminPassword('Change admin password');
if (!oldPw) return;
const newPw = window.prompt('Enter new admin password:');
if (!newPw) return;
const confirmPw = window.prompt('Confirm new admin password:');
if (newPw !== confirmPw) { setTamperStatus('Passwords do not match.', 'error'); return; }
const result = await TamperGuard.changePassword(oldPw, newPw);
if (result.success) {
setTamperStatus('Admin password changed.', 'ok');
} else {
setTamperStatus(result.reason, 'error');
}
});
}
function setTamperStatus(msg, type) {
const el = $('#tamperStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
/**
* Show the admin auth dialog and return the password, or null if cancelled.
*/
function promptAdminPassword(reason) {
return new Promise((resolve) => {
const dialog = $('#adminAuthDialog');
$('#adminAuthReason').textContent = reason;
$('#adminAuthInput').value = '';
$('#adminAuthStatus').textContent = '';
dialog.showModal();
const submit = () => {
const pw = $('#adminAuthInput').value;
if (!pw) {
$('#adminAuthStatus').textContent = 'Enter password.';
return;
}
dialog.close();
cleanup();
resolve(pw);
};
const cancel = () => {
dialog.close();
cleanup();
resolve(null);
};
const onKey = (e) => { if (e.key === 'Enter') submit(); };
const cleanup = () => {
$('#btnAdminAuthSubmit').removeEventListener('click', submit);
$('#btnAdminAuthCancel').removeEventListener('click', cancel);
$('#adminAuthInput').removeEventListener('keydown', onKey);
};
$('#btnAdminAuthSubmit').addEventListener('click', submit);
$('#btnAdminAuthCancel').addEventListener('click', cancel);
$('#adminAuthInput').addEventListener('keydown', onKey);
setTimeout(() => $('#adminAuthInput').focus(), 100);
});
}
// ----------------------------------------------------------------
// Conflict Resolution UI
// ----------------------------------------------------------------
async function checkConflicts() {
const result = await api.storage.local.get('ss_sync_conflicts');
const conflicts = result.ss_sync_conflicts || [];
const section = $('#conflictSection');
if (conflicts.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
renderConflicts(conflicts);
}
function renderConflicts(conflicts) {
const list = $('#conflictList');
list.innerHTML = conflicts.map(c => `
<div style="padding:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;margin-bottom:8px" data-conflict-id="${c.id}">
<div style="font-size:12px;font-weight:500;margin-bottom:6px">${escapeHtml(c.path)}</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
<div style="flex:1;min-width:120px">
<div style="font-size:10px;color:#6b7280;margin-bottom:2px">LOCAL (this device)</div>
<code style="font-size:11px;background:#f3f4f6;padding:4px 6px;border-radius:4px;display:block;word-break:break-all">${escapeHtml(JSON.stringify(c.localValue))}</code>
</div>
<div style="flex:1;min-width:120px">
<div style="font-size:10px;color:#6b7280;margin-bottom:2px">REMOTE (other device)</div>
<code style="font-size:11px;background:#f3f4f6;padding:4px 6px;border-radius:4px;display:block;word-break:break-all">${escapeHtml(JSON.stringify(c.remoteValue))}</code>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-primary btn-resolve" data-id="${c.id}" data-choice="local">Keep Local</button>
<button class="btn btn-sm btn-resolve" data-id="${c.id}" data-choice="remote">Keep Remote</button>
</div>
</div>
`).join('');
list.querySelectorAll('.btn-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
const conflictId = btn.dataset.id;
const choice = btn.dataset.choice;
const result = await api.storage.local.get('ss_sync_conflicts');
const conflicts = result.ss_sync_conflicts || [];
const conflict = conflicts.find(c => c.id === conflictId);
if (conflict) {
// Apply resolution
const local = await SilentSendSync._getAllData();
SilentSendMerge.resolveConflict(local, conflict, choice);
await SilentSendSync._applyData(local, 'conflict-resolution');
// Remove resolved conflict
const remaining = conflicts.filter(c => c.id !== conflictId);
await api.storage.local.set({ ss_sync_conflicts: remaining });
// Refresh
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
checkConflicts();
}
});
});
}
// ----------------------------------------------------------------
// Utility
// ----------------------------------------------------------------
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;