fix: cross-browser sync import corrupting local encryption + v2.0.9

Root cause: when importing an encrypted sync code from another device,
_decryptFromSync was replacing the local encryption config (salt) with
the source device's salt. This caused:
1. Local key cache derived from wrong salt
2. Local data (encrypted with local salt) became unreadable
3. _applyData tried to write with the wrong key

Fixed with a complete refactor of cross-device decryption:
- authenticateForSync() derives a TEMPORARY key using the source salt
- Temporary key stored separately as 'tempSyncKey' in IndexedDB
- Local encryption config and cached key are NEVER modified
- After decryption, _applyData writes via _writeSecure using the
  LOCAL key (which uses the local salt)
- _handleDecryptedMeta extracted for code reuse

Also:
- Options.js auth handler detects pending sync import and routes to
  authenticateForSync instead of regular authenticate
- After auth success, automatically retries the import
- README updated: imported passwords are protected (dots in UI,
  vault password to reveal, AES-256 encrypted at rest)
- Bumped to v2.0.9

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-27 05:23:50 +00:00
parent f2fa4befdd
commit 97e37e65ac
7 changed files with 108 additions and 50 deletions
+1 -1
View File
@@ -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 CSV (2 columns) | Real → substitute pairs |
| Plain text (1 per line) | Auto-categorized values needing substitutes | | 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**. Go to **Options****Transfer Data****Import CSV / Password Export**.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Silent Send", "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.", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Silent Send", "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.", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
"permissions": [ "permissions": [
"storage", "storage",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "silent-send", "name": "silent-send",
"version": "2.0.7", "version": "2.0.9",
"private": true, "private": true,
"license": "BSL-1.1", "license": "BSL-1.1",
"description": "Browser extension that substitutes personal data before sending to AI services", "description": "Browser extension that substitutes personal data before sending to AI services",
+91 -38
View File
@@ -460,47 +460,100 @@ const SilentSendSync = {
async _decryptFromSync(data) { async _decryptFromSync(data) {
if (!data?._ssEncrypted) return { data, decrypted: false }; if (!data?._ssEncrypted) return { data, decrypted: false };
// Always use the sync envelope's salt/verificationBlob for decryption, // Decrypt using the SOURCE device's salt (embedded in the sync envelope).
// not the local config. Different devices have different salts, so the // We derive a TEMPORARY key — never modify the local config or cached key,
// local key won't decrypt data encrypted with another device's salt. // because the local data is encrypted with the LOCAL salt.
let config = await this._getSyncEncryption(); 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);
}
return { data: null, decrypted: false, needsAuth: true };
}
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); const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key);
return this._handleDecryptedMeta(decrypted);
} catch {
return { data: null, decrypted: false, needsAuth: true };
}
}
// 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 // Restore full encryption config from inner metadata
if (decrypted._encMeta) { if (decrypted._encMeta) {
const fullConfig = await this._getSyncEncryption(); const fullConfig = await this._getSyncEncryption();
+1 -1
View File
@@ -591,7 +591,7 @@
</section> </section>
<footer> <footer>
<p>Silent Send v2.0.7</p> <p>Silent Send v2.0.9</p>
</footer> </footer>
</div> </div>
+11 -6
View File
@@ -1016,24 +1016,29 @@ async function initSyncEncryptionUI() {
return; return;
} }
// Check if this is re-verification (key exists) or first-device (needs full auth)
const cached = await SilentSendCrypto.getCachedKey();
let result; let result;
// 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 {
// Normal auth: check if re-verification or first-device
const cached = await SilentSendCrypto.getCachedKey();
if (cached) { if (cached) {
// Re-verification — password alone is enough
result = await SilentSendSync.reverifyWithPassword(password); result = await SilentSendSync.reverifyWithPassword(password);
} else { } else {
// First device — full auth with password + TOTP if configured
result = await SilentSendSync.authenticate(password, totpCode || undefined); result = await SilentSendSync.authenticate(password, totpCode || undefined);
} }
}
if (result.success) { if (result.success) {
$('#syncAuthPrompt').style.display = 'none'; $('#syncAuthPrompt').style.display = 'none';
$('#syncAuthPassword').value = ''; $('#syncAuthPassword').value = '';
$('#syncAuthTOTPForPassword').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 that auth succeeded // If there's a pending sync import, retry it now
if (window.__ssPendingSyncImport) { if (window.__ssPendingSyncImport) {
const retry = window.__ssPendingSyncImport; const retry = window.__ssPendingSyncImport;
window.__ssPendingSyncImport = null; window.__ssPendingSyncImport = null;