From a22ba549a2fc4c170533bc28b121d583ff2d7a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 19:03:54 +0000 Subject: [PATCH] feat: at-rest encryption for all sensitive data + vault unlock flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All sensitive data (identity, mappings, activity log) is now AES-256 encrypted in browser.storage.local when sync encryption is enabled. TOTP secret is also encrypted at rest using the derived key. Vault unlock flow: - On browser restart, extension detects locked state (encrypted data, no cached CryptoKey) and shows LOCK badge in red - Popup shows a full-screen unlock prompt with password field, optional TOTP, and biometric button - After unlock, background decrypts and broadcasts data to all tabs - Content scripts start with empty config when locked; receive decrypted config via vault:unlocked message after unlock - Injector skips encrypted blobs in storage change events Storage module changes: - _readSecure / _writeSecure transparently encrypt/decrypt - encryptExistingData() migrates plaintext → encrypted on setup - decryptAllData() restores plaintext when encryption is disabled - isLocked() checks for encrypted data + missing key https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn --- src/background/service-worker.js | 46 ++++++++++ src/content/injector.js | 41 +++++++-- src/lib/storage.js | 146 ++++++++++++++++++++++++++++--- src/lib/sync.js | 87 ++++++++++++++---- src/options/options.js | 5 +- src/popup/popup.html | 26 +++++- src/popup/popup.js | 108 ++++++++++++++++++++++- 7 files changed, 418 insertions(+), 41 deletions(-) diff --git a/src/background/service-worker.js b/src/background/service-worker.js index a93cb58..2d46e2a 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -153,6 +153,44 @@ const messageHandlers = { api.action.setBadgeBackgroundColor({ color: '#6b7280' }); }, + async 'get:locked-state'(_message, _sender, sendResponse) { + const locked = await Storage.isLocked(); + sendResponse({ locked }); + }, + + async 'vault:unlocked'() { + // User unlocked the vault — clear the LOCK badge and refresh icon + api.action.setBadgeText({ text: '' }); + const settings = await Storage.getSettings(); + await updateIcon(settings); + + // Now that we're unlocked, try syncing + if (settings.browserSync) { + await SilentSendSync.pullFromSyncStorage(); + } + + // Read decrypted data via Storage module and send to all content scripts + const mappings = await Storage.getMappings(); + const identity = await Storage.getIdentity(); + + const allPatterns = [...BUILTIN_URL_PATTERNS]; + const customDomains = settings.customDomains || []; + for (const domain of customDomains) { + allPatterns.push(domain + '/*'); + } + for (const urlPattern of allPatterns) { + const tabs = await api.tabs.query({ url: urlPattern }).catch(() => []); + for (const tab of tabs) { + api.tabs.sendMessage(tab.id, { + type: 'vault:unlocked', + mappings, + identity, + settings, + }).catch(() => {}); + } + } + }, + async 'update:settings'(message) { await Storage.saveSettings(message.settings); @@ -372,6 +410,14 @@ api.runtime.onInstalled.addListener(async () => { const settings = await Storage.getSettings(); await updateIcon(settings); + // Check if extension is locked (encrypted data, no cached key) + const locked = await Storage.isLocked(); + if (locked) { + api.action.setBadgeText({ text: 'LOCK' }); + api.action.setBadgeBackgroundColor({ color: '#dc2626' }); + return; // don't try to sync while locked + } + // Restore the SYN badge if the user hasn't opened Options since the last sync const stored = await api.storage.local.get('ss_sync_notification'); if (stored.ss_sync_notification) { diff --git a/src/content/injector.js b/src/content/injector.js index fc11458..7bf78c4 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -58,11 +58,16 @@ // Load mappings and settings, then inject into page async function init() { const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']); - const mappings = result.ss_mappings || []; const settings = result.ss_settings || { enabled: true }; + // Check if data is encrypted (locked) — pass empty config + // The background will send decrypted data via vault:unlocked when ready + const isLocked = result.ss_mappings?._ssLocalEncrypted || + result.ss_identity?._ssLocalEncrypted; + const mappings = isLocked ? [] : (result.ss_mappings || []); + const identityData = isLocked ? {} : (result.ss_identity || {}); + // Merge active profiles into a flat identity object for the content script - const identityData = result.ss_identity || {}; const identity = mergeProfiles(identityData); // Inject the main interception script into the page's world @@ -107,24 +112,46 @@ }); // Forward storage changes to the page script (merge profiles before sending) + // Skip encrypted blobs — background will send decrypted data via vault:unlocked api.storage.onChanged.addListener((changes) => { if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) { const msg = { type: 'ss:config-updated' }; - if (changes.ss_mappings) msg.mappings = changes.ss_mappings.newValue; - if (changes.ss_identity) msg.identity = mergeProfiles(changes.ss_identity.newValue); + + if (changes.ss_mappings) { + const val = changes.ss_mappings.newValue; + if (!val?._ssLocalEncrypted) msg.mappings = val; + } + if (changes.ss_identity) { + const val = changes.ss_identity.newValue; + if (!val?._ssLocalEncrypted) msg.identity = mergeProfiles(val); + } if (changes.ss_settings) msg.settings = changes.ss_settings.newValue; - window.postMessage(msg, '*'); + + // Only post if we have something meaningful to send + if (msg.mappings || msg.identity || msg.settings) { + window.postMessage(msg, '*'); + } } }); - // Listen for settings updates from popup via runtime messages - api.runtime.onMessage.addListener((message) => { + // Listen for settings updates and vault unlock from background + api.runtime.onMessage.addListener(async (message) => { if (message.type === 'settings:updated') { window.postMessage({ type: 'ss:config-updated', settings: message.settings, }, '*'); } + + // Vault unlocked — background sends pre-decrypted data + if (message.type === 'vault:unlocked') { + window.postMessage({ + type: 'ss:config-updated', + mappings: message.mappings || [], + identity: message.identity || {}, + settings: message.settings || {}, + }, '*'); + } }); // Storage bridge — lets page world script read/write storage diff --git a/src/lib/storage.js b/src/lib/storage.js index a0ed709..98e30cd 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -3,9 +3,18 @@ * * Wraps browser/api.storage.local with typed helpers for mappings, * activity log, and settings. + * + * When at-rest encryption is enabled, sensitive data (identity, mappings, + * activity log) is AES-encrypted before writing to storage.local and + * decrypted on read. The encryption key comes from the key cache in + * IndexedDB (derived from the user's password on first setup). + * + * Non-sensitive data (settings, sync metadata) remains plaintext so the + * extension can function in a "locked" state (showing the unlock prompt). */ import api from './browser-polyfill.js'; +import SilentSendCrypto from './crypto.js'; const KEYS = { MAPPINGS: 'ss_mappings', @@ -14,6 +23,9 @@ const KEYS = { SETTINGS: 'ss_settings', }; +// Keys that contain sensitive PPI and should be encrypted at rest +const ENCRYPTED_KEYS = new Set([KEYS.MAPPINGS, KEYS.IDENTITY, KEYS.LOG]); + const DEFAULT_SETTINGS = { enabled: true, showHighlights: false, @@ -29,15 +41,128 @@ const DEFAULT_SETTINGS = { }; const Storage = { + // ---------------------------------------------------------------- + // At-rest encryption helpers + // ---------------------------------------------------------------- + + /** + * Check if at-rest encryption is enabled. + * At-rest encryption piggybacks on sync encryption — if the user + * has set up sync encryption, local storage is also encrypted. + */ + async _isAtRestEncryptionEnabled() { + const result = await api.storage.local.get('ss_sync_encryption'); + return !!result.ss_sync_encryption?.enabled; + }, + + /** + * Read a potentially-encrypted value from storage. + * Returns the decrypted value, or null if locked. + */ + async _readSecure(key) { + const result = await api.storage.local.get(key); + const value = result[key]; + + // Not encrypted — return as-is + if (!value || !value._ssLocalEncrypted) return value || null; + + // Encrypted — need the cached key + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return null; // locked + + try { + return await SilentSendCrypto.decryptWithKey(value.data, cached.key); + } catch { + return null; // corrupted or wrong key + } + }, + + /** + * Write a value to storage, encrypting if at-rest encryption is enabled. + */ + async _writeSecure(key, value, extras = {}) { + const isEncEnabled = await this._isAtRestEncryptionEnabled(); + + if (isEncEnabled && ENCRYPTED_KEYS.has(key)) { + const cached = await SilentSendCrypto.getCachedKey(); + if (cached) { + const encrypted = await SilentSendCrypto.encryptWithKey(value, cached.key); + await api.storage.local.set({ + [key]: { _ssLocalEncrypted: true, data: encrypted }, + ...extras, + }); + return; + } + // No key available — fall through to plaintext (shouldn't happen + // if the UI flow is correct, but better than losing data) + } + + await api.storage.local.set({ [key]: value, ...extras }); + }, + + /** + * Check if the extension is in a locked state (encrypted data, no key). + */ + async isLocked() { + const isEncEnabled = await this._isAtRestEncryptionEnabled(); + if (!isEncEnabled) return false; + + const cached = await SilentSendCrypto.getCachedKey(); + if (cached) return false; + + return true; + }, + + /** + * Encrypt all existing plaintext sensitive data after encryption is + * first enabled. Called once when the user sets up sync encryption. + */ + async encryptExistingData() { + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return; + + for (const key of ENCRYPTED_KEYS) { + const result = await api.storage.local.get(key); + const value = result[key]; + // Skip if already encrypted or empty + if (!value || value._ssLocalEncrypted) continue; + + const encrypted = await SilentSendCrypto.encryptWithKey(value, cached.key); + await api.storage.local.set({ + [key]: { _ssLocalEncrypted: true, data: encrypted }, + }); + } + }, + + /** + * Decrypt all encrypted data back to plaintext. Called when the user + * disables sync encryption. + */ + async decryptAllData() { + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return; + + for (const key of ENCRYPTED_KEYS) { + const result = await api.storage.local.get(key); + const value = result[key]; + if (!value?._ssLocalEncrypted) continue; + + try { + const decrypted = await SilentSendCrypto.decryptWithKey(value.data, cached.key); + await api.storage.local.set({ [key]: decrypted }); + } catch { /* leave encrypted if decryption fails */ } + } + }, + // --- Mappings --- async getMappings() { - const result = await api.storage.local.get(KEYS.MAPPINGS); - return result[KEYS.MAPPINGS] || []; + const data = await this._readSecure(KEYS.MAPPINGS); + return data || []; }, async saveMappings(mappings) { - await api.storage.local.set({ [KEYS.MAPPINGS]: mappings, ss_lastModified: Date.now() }); + await this._writeSecure(KEYS.MAPPINGS, mappings, { ss_lastModified: Date.now() }); }, async addMapping(mapping) { @@ -90,14 +215,13 @@ const Storage = { }, async getProfiles() { - const result = await api.storage.local.get(KEYS.IDENTITY); - const data = result[KEYS.IDENTITY]; + const data = await this._readSecure(KEYS.IDENTITY); if (data?.profiles) return data.profiles; return []; }, async saveProfiles(profiles) { - await api.storage.local.set({ [KEYS.IDENTITY]: { profiles }, ss_lastModified: Date.now() }); + await this._writeSecure(KEYS.IDENTITY, { profiles }, { ss_lastModified: Date.now() }); }, async addProfile(name) { @@ -174,8 +298,8 @@ const Storage = { // --- Activity Log --- async getLog() { - const result = await api.storage.local.get(KEYS.LOG); - return result[KEYS.LOG] || []; + const data = await this._readSecure(KEYS.LOG); + return data || []; }, async addLogEntry(entry) { @@ -193,14 +317,16 @@ const Storage = { log.length = settings.maxLogEntries; } - await api.storage.local.set({ [KEYS.LOG]: log }); + await this._writeSecure(KEYS.LOG, log); }, async clearLog() { - await api.storage.local.set({ [KEYS.LOG]: [] }); + await this._writeSecure(KEYS.LOG, []); }, // --- Settings --- + // Settings are NOT encrypted — they contain no PPI and are needed + // for the extension to show basic UI (locked state, badge, etc.) async getSettings() { const result = await api.storage.local.get(KEYS.SETTINGS); diff --git a/src/lib/sync.js b/src/lib/sync.js index e2a43af..46b00ac 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -43,7 +43,36 @@ const SilentSendSync = { }, async _saveSyncEncryption(config) { - await api.storage.local.set({ ss_sync_encryption: config }); + // Encrypt the TOTP secret at rest if we have a cached key + const toStore = { ...config }; + if (toStore.totpSecret) { + const cached = await SilentSendCrypto.getCachedKey(); + if (cached) { + toStore._totpEncrypted = await SilentSendCrypto.encryptWithKey( + { secret: toStore.totpSecret }, cached.key + ); + delete toStore.totpSecret; // don't store plaintext + } + } + await api.storage.local.set({ ss_sync_encryption: toStore }); + }, + + /** + * Get the decrypted TOTP secret (if configured and key is available). + */ + async _getTOTPSecret(config) { + if (config.totpSecret) return config.totpSecret; // already plaintext (legacy) + if (!config._totpEncrypted) return null; + + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return null; + + try { + const decrypted = await SilentSendCrypto.decryptWithKey(config._totpEncrypted, cached.key); + return decrypted.secret; + } catch { + return null; + } }, /** @@ -150,17 +179,10 @@ const SilentSendSync = { const config = await this._getSyncEncryption(); if (!config?.enabled) return { success: true }; - // Validate TOTP if configured - if (config.totpSecret) { - if (!totpCode) return { success: false, reason: 'TOTP code required.' }; - const valid = await SilentSendCrypto.validateTOTP(config.totpSecret, totpCode); - if (!valid) return { success: false, reason: 'Invalid TOTP code.' }; - } - - // Derive key from password using stored salt + // Derive key from password first (needed to decrypt TOTP secret) const { key, salt } = await SilentSendCrypto.deriveAndReturnKey(password, config.salt); - // Verify the password is correct by trying to decrypt the verification blob + // Verify the password is correct if (config.verificationBlob) { try { await SilentSendCrypto.decryptWithKey(config.verificationBlob, key); @@ -169,6 +191,22 @@ const SilentSendSync = { } } + // Validate TOTP if configured (after deriving key, since TOTP secret + // may be encrypted at rest and needs the key to decrypt) + const hasTOTP = config.totpSecret || config._totpEncrypted; + if (hasTOTP) { + if (!totpCode) return { success: false, reason: 'TOTP code required.' }; + // Temporarily cache key so _getTOTPSecret can decrypt + await SilentSendCrypto.cacheKey(key, salt, config.ttlDays ?? 90); + const secret = await this._getTOTPSecret(config); + if (!secret) return { success: false, reason: 'Could not decrypt TOTP secret.' }; + const valid = await SilentSendCrypto.validateTOTP(secret, totpCode); + if (!valid) { + await SilentSendCrypto.clearCachedKey(); // don't leave key cached on TOTP failure + return { success: false, reason: 'Invalid TOTP code.' }; + } + } + // Cache the key persistently (never auto-deletes) const ttlDays = config.ttlDays ?? 90; await SilentSendCrypto.cacheKey(key, salt, ttlDays); @@ -200,14 +238,18 @@ const SilentSendSync = { async reverifyWithTOTP(totpCode) { const config = await this._getSyncEncryption(); if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' }; - if (!config.totpSecret) return { success: false, reason: 'TOTP not configured.' }; + + const hasTOTP = config.totpSecret || config._totpEncrypted; + if (!hasTOTP) return { success: false, reason: 'TOTP not configured.' }; // Must have a cached key — TOTP can't derive one const cached = await SilentSendCrypto.getCachedKey(); if (!cached) return { success: false, reason: 'No cached key. Password required for first setup.' }; - // Validate the TOTP code - const valid = await SilentSendCrypto.validateTOTP(config.totpSecret, totpCode); + // Decrypt the TOTP secret and validate + const secret = await this._getTOTPSecret(config); + if (!secret) return { success: false, reason: 'Could not decrypt TOTP secret.' }; + const valid = await SilentSendCrypto.validateTOTP(secret, totpCode); if (!valid) return { success: false, reason: 'Invalid TOTP code.' }; // Reset the TTL timer @@ -287,19 +329,20 @@ const SilentSendSync = { webauthn: enableWebAuthn, }; + // Cache the key immediately (needed before _saveSyncEncryption + // can encrypt the TOTP secret) + await SilentSendCrypto.cacheKey(key, salt, ttlDays); + let totpSecret, totpURI; if (enableTOTP) { totpSecret = SilentSendCrypto.generateTOTPSecret(); totpURI = SilentSendCrypto.totpURI(totpSecret); - config.totpSecret = totpSecret; + config.totpSecret = totpSecret; // _saveSyncEncryption will encrypt this if (authMethod === 'password') config.authMethod = 'both'; } await this._saveSyncEncryption(config); - // Cache the key immediately - await SilentSendCrypto.cacheKey(key, salt, ttlDays); - // Set up WebAuthn if requested if (enableWebAuthn && SilentSendCrypto.isWebAuthnAvailable()) { try { @@ -312,6 +355,10 @@ const SilentSendSync = { } } + // Encrypt any existing plaintext sensitive data in storage + const StorageModule = (await import('./storage.js')).default; + await StorageModule.encryptExistingData(); + return { success: true, totpSecret, totpURI }; }, @@ -319,6 +366,10 @@ const SilentSendSync = { * Disable sync encryption entirely. */ async disableEncryption() { + // Decrypt all data back to plaintext before removing encryption config + const StorageModule = (await import('./storage.js')).default; + await StorageModule.decryptAllData(); + await api.storage.local.remove('ss_sync_encryption'); await SilentSendCrypto.clearCachedKey(); await SilentSendCrypto.clearWebAuthnCredential(); @@ -379,7 +430,7 @@ const SilentSendSync = { authMethod: config.authMethod, ttlDays: config.ttlDays, webauthn: config.webauthn, - totpSecret: config.totpSecret || null, + totpSecret: await this._getTOTPSecret(config) || null, }, }; diff --git a/src/options/options.js b/src/options/options.js index 8cda4a1..49931a1 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -1002,7 +1002,8 @@ async function showSyncAuthPrompt(mode = 'decrypt') { } // First-device: show TOTP alongside password if configured - if (!isReverify && config?.totpSecret) { + const hasTOTP = config?.totpSecret || config?._totpEncrypted; + if (!isReverify && hasTOTP) { $('#syncAuthTOTPForPassword').style.display = ''; } else { $('#syncAuthTOTPForPassword').style.display = 'none'; @@ -1023,7 +1024,7 @@ async function showSyncAuthPrompt(mode = 'decrypt') { // TOTP re-verify option const totpGroup = $('#totpReverifyGroup'); - if (config?.totpSecret) { + if (hasTOTP) { totpGroup.style.display = 'flex'; } else { totpGroup.style.display = 'none'; diff --git a/src/popup/popup.html b/src/popup/popup.html index 264b1fb..7fe5c6d 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -35,6 +35,27 @@ + + +