diff --git a/README.md b/README.md index 54702b8..6cc9d0f 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Import your existing data from password managers and browser autofill to pre-pop | Plain CSV (2 columns) | Real → substitute pairs | | Plain text (1 per line) | Auto-categorized values needing substitutes | -Passwords are imported as exact-match mappings (e.g. `MyS3cret!` → `[REDACTED-PASSWORD-1]`) so they get caught in any context — not just `password=value` patterns. +Passwords are imported as exact-match mappings (e.g. `MyS3cret!` → `[REDACTED-PASSWORD-1]`) so they get caught in any context — not just `password=value` patterns. Imported passwords are protected: they're shown as dots in the UI and require your vault encryption password to reveal. When at-rest encryption is enabled, imported passwords are AES-256 encrypted in storage like all other sensitive data. Go to **Options** → **Transfer Data** → **Import CSV / Password Export**. diff --git a/manifest.firefox.json b/manifest.firefox.json index 780b1e5..f18f9a4 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "2.0.7", + "version": "2.0.9", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "browser_specific_settings": { "gecko": { diff --git a/manifest.json b/manifest.json index 443a6fc..148938d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "2.0.7", + "version": "2.0.9", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "permissions": [ "storage", diff --git a/package.json b/package.json index f6cced1..455ed17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "silent-send", - "version": "2.0.7", + "version": "2.0.9", "private": true, "license": "BSL-1.1", "description": "Browser extension that substitutes personal data before sending to AI services", diff --git a/src/content/injector.js b/src/content/injector.js index edd9a0e..a00721b 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -15,6 +15,25 @@ if (window.__silentSendInjected) return; window.__silentSendInjected = true; + // IMMEDIATELY inject a synchronous fetch hook into the page world + // BEFORE any async operations. This must run before any page JS + // (like ChatGPT's Next.js) can store a reference to the original fetch. + const earlyHook = document.createElement('script'); + earlyHook.textContent = `(function(){ + window.__ssOriginalFetch = window.fetch; + window.__ssOriginalXHROpen = XMLHttpRequest.prototype.open; + window.__ssOriginalXHRSend = XMLHttpRequest.prototype.send; + window.__ssReady = false; + window.fetch = function() { + if (window.__ssReady && window.__ssInterceptFetch) { + return window.__ssInterceptFetch.apply(this, arguments); + } + return window.__ssOriginalFetch.apply(this, arguments); + }; + })();`; + (document.head || document.documentElement).appendChild(earlyHook); + earlyHook.remove(); + // Merge active profiles into flat identity object function mergeProfiles(data) { const profiles = data?.profiles || []; @@ -70,32 +89,8 @@ // Merge active profiles into a flat identity object for the content script const identity = mergeProfiles(identityData); - // STEP 1: Inject a synchronous inline script that patches fetch/XHR - // IMMEDIATELY, before any page JS can store a reference to the originals. - // This thin proxy queues calls until the full content.js loads. - const earlyHook = document.createElement('script'); - earlyHook.textContent = `(function(){ - // Store the real fetch/XHR before any page script can - window.__ssOriginalFetch = window.fetch; - window.__ssOriginalXHROpen = XMLHttpRequest.prototype.open; - window.__ssOriginalXHRSend = XMLHttpRequest.prototype.send; - window.__ssReady = false; - window.__ssQueue = []; - - // Replace fetch with a proxy that queues until content.js is ready - window.fetch = function() { - if (window.__ssReady && window.__ssInterceptFetch) { - return window.__ssInterceptFetch.apply(this, arguments); - } - // If not ready yet, call original (no substitution possible) - return window.__ssOriginalFetch.apply(this, arguments); - }; - })();`; - (document.head || document.documentElement).appendChild(earlyHook); - earlyHook.remove(); - - // STEP 2: Load the full content.js which will use __ssOriginalFetch - // and set __ssReady = true when it's done hooking + // Load the full content.js which will use __ssOriginalFetch + // (captured by the early hook above) and set __ssReady = true const script = document.createElement('script'); script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings })); script.src = api.runtime.getURL('src/content/content.js'); diff --git a/src/lib/sync.js b/src/lib/sync.js index cd44c62..d38044b 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -464,47 +464,100 @@ const SilentSendSync = { async _decryptFromSync(data) { if (!data?._ssEncrypted) return { data, decrypted: false }; - // Always use the sync envelope's salt/verificationBlob for decryption, - // not the local config. Different devices have different salts, so the - // local key won't decrypt data encrypted with another device's salt. - let config = await this._getSyncEncryption(); + // Decrypt using the SOURCE device's salt (embedded in the sync envelope). + // We derive a TEMPORARY key — never modify the local config or cached key, + // because the local data is encrypted with the LOCAL salt. + const sourceSalt = data._encConfig?.salt; + const sourceVerification = data._encConfig?.verificationBlob; - if (data._encConfig) { - // Use the source's salt for this decryption, but don't overwrite - // the local config permanently yet — only if decryption succeeds - config = { - ...(config || {}), - enabled: true, - salt: data._encConfig.salt, - verificationBlob: data._encConfig.verificationBlob, - }; - } - - if (!config?.enabled) { - return { data: null, decrypted: false, needsAuth: true }; - } - - // Try to get a key using the sync envelope's salt - // First check if we have a cached key that matches - let keyInfo = await SilentSendCrypto.getCachedKey(); - - // If the cached key's salt doesn't match the sync data's salt, - // we need to re-derive from the password - if (keyInfo && data._encConfig && keyInfo.salt !== data._encConfig.salt) { - keyInfo = null; // force re-auth with the correct salt - } - - if (!keyInfo) { - // Need the user to enter the password — save the sync salt temporarily - // so authenticate() uses it to derive the correct key - if (data._encConfig) { - await this._saveSyncEncryption(config); + if (!sourceSalt) { + // No embedded config — try local key (same-device sync like browser.storage.sync) + const keyInfo = await SilentSendCrypto.getCachedKey(); + if (!keyInfo) return { data: null, decrypted: false, needsAuth: true }; + try { + const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key); + return this._handleDecryptedMeta(decrypted); + } catch { + return { data: null, decrypted: false, needsAuth: true }; } - return { data: null, decrypted: false, needsAuth: true }; } - const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key); + // Cross-device: need to derive a temporary key from the source salt. + // Check if we have a stored password-derived key for this source salt. + const tempKeyStore = await this._getTempSyncKey(sourceSalt); + if (tempKeyStore) { + try { + const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, tempKeyStore.key); + return this._handleDecryptedMeta(decrypted); + } catch { /* wrong key, fall through to prompt */ } + } + // Need the user's password to derive a key with the source salt. + // Store the source salt temporarily so the auth prompt can use it. + this._pendingSyncSalt = sourceSalt; + this._pendingSyncVerification = sourceVerification; + return { data: null, decrypted: false, needsAuth: true, syncSalt: sourceSalt }; + }, + + /** + * Authenticate specifically for a cross-device sync import. + * Derives a temporary key with the source device's salt. + * Does NOT modify the local encryption config or cached key. + */ + async authenticateForSync(password) { + const salt = this._pendingSyncSalt; + const verification = this._pendingSyncVerification; + if (!salt) return { success: false, reason: 'No pending sync import.' }; + + // Derive key with the source salt + const { key } = await SilentSendCrypto.deriveAndReturnKey(password, salt); + + // Verify password against the source's verification blob + if (verification) { + try { + await SilentSendCrypto.decryptWithKey(verification, key); + } catch { + return { success: false, reason: 'Wrong password.' }; + } + } + + // Store this temporary key for the source salt (NOT in the main cache) + await this._storeTempSyncKey(salt, key); + this._pendingSyncSalt = null; + this._pendingSyncVerification = null; + + return { success: true }; + }, + + async _storeTempSyncKey(salt, key) { + try { + const db = await SilentSendCrypto._openCacheDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').put({ key, salt, storedAt: Date.now() }, 'tempSyncKey'); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + } catch { /* non-fatal */ } + }, + + async _getTempSyncKey(salt) { + try { + const db = await SilentSendCrypto._openCacheDB(); + return new Promise((resolve) => { + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('tempSyncKey'); + req.onsuccess = () => { + const entry = req.result; + if (entry?.key && entry.salt === salt) resolve(entry); + else resolve(null); + }; + req.onerror = () => resolve(null); + }); + } catch { return null; } + }, + + async _handleDecryptedMeta(decrypted) { // Restore full encryption config from inner metadata if (decrypted._encMeta) { const fullConfig = await this._getSyncEncryption(); diff --git a/src/options/options.html b/src/options/options.html index 24b096b..06ae862 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -591,7 +591,7 @@ diff --git a/src/options/options.js b/src/options/options.js index 8055403..0adafc4 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -104,15 +104,20 @@ document.addEventListener('DOMContentLoaded', async () => { setSyncStatus('', 'neutral'); }); - $('#btnApplySyncCode').addEventListener('click', async () => { + $('#btnApplySyncCode').addEventListener('click', applySyncCode); + + async function applySyncCode() { const code = $('#syncImportText').value.trim(); if (!code) return; const force = $('#syncForce').checked; const result = await SilentSendSync.importSyncCode(code, { force }); if (result.needsAuth) { - setSyncStatus('Authentication required to decrypt this sync code.', 'warn'); - showSyncAuthPrompt(); + setSyncStatus('Authentication required — enter your encryption password, then the import will continue.', 'warn'); + showSyncAuthPrompt('decrypt'); + // After auth succeeds, retry the import automatically + window.__ssPendingSyncImport = applySyncCode; } else if (result.success) { + window.__ssPendingSyncImport = null; setSyncStatus(`Imported successfully (data from ${result.importTime}).`, 'ok'); $('#syncImportSection').style.display = 'none'; $('#syncImportText').value = ''; @@ -120,6 +125,7 @@ document.addEventListener('DOMContentLoaded', async () => { settings = await Storage.getSettings(); $('#browserSync').checked = settings.browserSync === true; renderMappings(); + renderPasswords(); renderDomains(); renderLog(); } else if (result.skipped) { @@ -130,7 +136,7 @@ document.addEventListener('DOMContentLoaded', async () => { } else { setSyncStatus(`Failed: ${result.reason}`, 'error'); } - }); + } $('#btnCancelSyncImport').addEventListener('click', () => { $('#syncImportSection').style.display = 'none'; @@ -1034,22 +1040,34 @@ async function initSyncEncryptionUI() { return; } - // Check if this is re-verification (key exists) or first-device (needs full auth) - const cached = await SilentSendCrypto.getCachedKey(); let result; - if (cached) { - // Re-verification — password alone is enough - result = await SilentSendSync.reverifyWithPassword(password); + + // If there's a pending sync import, use authenticateForSync + // (derives a temporary key with the source salt, doesn't touch local config) + if (window.__ssPendingSyncImport) { + result = await SilentSendSync.authenticateForSync(password); } else { - // First device — full auth with password + TOTP if configured - result = await SilentSendSync.authenticate(password, totpCode || undefined); + // Normal auth: check if re-verification or first-device + const cached = await SilentSendCrypto.getCachedKey(); + if (cached) { + result = await SilentSendSync.reverifyWithPassword(password); + } else { + result = await SilentSendSync.authenticate(password, totpCode || undefined); + } } if (result.success) { $('#syncAuthPrompt').style.display = 'none'; $('#syncAuthPassword').value = ''; $('#syncAuthTOTPForPassword').value = ''; - setSyncEncStatus(cached ? 'Re-verified with password.' : 'Authenticated. Sync data unlocked.', 'ok'); + setSyncEncStatus('Authenticated.', 'ok'); + + // If there's a pending sync import, retry it now + if (window.__ssPendingSyncImport) { + const retry = window.__ssPendingSyncImport; + window.__ssPendingSyncImport = null; + await retry(); + } } else { setSyncAuthStatus(result.reason, 'error'); }