From 7fd70e0891ca27b2bcca49603847769c4524c631 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 18:23:17 +0000 Subject: [PATCH 1/5] fix: WebAuthn as primary re-auth, key persists indefinitely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CryptoKey now persists in IndexedDB forever — never auto-deleted - TTL controls when re-verification is needed, not key lifetime - WebAuthn is the primary re-auth method (not a post-expiry fallback) - Password only needed once per device (first-time setup) - Added needsReverification() and markVerified() to crypto.js - Auth prompt adapts message: first-device vs re-verify vs decrypt - Biometric button hidden on first-device setup (no credential yet) https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn --- src/lib/crypto.js | 106 ++++++++++++++++++++++++++++----------- src/lib/sync.js | 89 ++++++++++++++++++++------------ src/options/options.html | 2 +- src/options/options.js | 56 ++++++++++++--------- 4 files changed, 170 insertions(+), 83 deletions(-) diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 78a038b..125b905 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -242,29 +242,37 @@ const SilentSendCrypto = { }, /** - * Cache a CryptoKey with a TTL. + * Cache a CryptoKey persistently. + * The key stays in IndexedDB indefinitely — it's only cleared if + * the user explicitly disables encryption or clears browser data. + * The TTL controls when re-verification (via WebAuthn) is required, + * NOT when the key is deleted. + * * @param {CryptoKey} key * @param {string} salt - base64-encoded salt used to derive this key - * @param {number} ttlDays - 0 = session only, -1 = never expire + * @param {number} ttlDays - re-verify interval: 0 = each session, -1 = never */ async cacheKey(key, salt, ttlDays = 90) { const db = await this._openCacheDB(); - const expiresAt = ttlDays === -1 - ? -1 // never + const reverifyAt = ttlDays === -1 + ? -1 // never re-verify : ttlDays === 0 - ? 0 // session only (cleared on browser restart by the caller) + ? 0 // re-verify each session : Date.now() + ttlDays * 86400000; return new Promise((resolve, reject) => { const tx = db.transaction('keys', 'readwrite'); - tx.objectStore('keys').put({ key, salt, expiresAt, cachedAt: Date.now() }, 'syncKey'); + tx.objectStore('keys').put({ key, salt, reverifyAt, cachedAt: Date.now() }, 'syncKey'); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); }, /** - * Retrieve cached key if not expired. + * Retrieve cached key. The key is always returned if it exists — + * it never expires or self-deletes. Use needsReverification() to + * check if the user should re-verify via WebAuthn. + * * Returns { key: CryptoKey, salt: string } or null. */ async getCachedKey() { @@ -275,22 +283,8 @@ const SilentSendCrypto = { const req = tx.objectStore('keys').get('syncKey'); req.onsuccess = () => { const entry = req.result; - if (!entry) return resolve(null); - - // Check expiry - if (entry.expiresAt === -1) { - // Never expires - resolve({ key: entry.key, salt: entry.salt }); - } else if (entry.expiresAt === 0) { - // Session only — always valid until explicitly cleared - resolve({ key: entry.key, salt: entry.salt }); - } else if (Date.now() < entry.expiresAt) { - resolve({ key: entry.key, salt: entry.salt }); - } else { - // Expired — clean up - this.clearCachedKey(); - resolve(null); - } + if (!entry?.key) return resolve(null); + resolve({ key: entry.key, salt: entry.salt }); }; req.onerror = () => resolve(null); }); @@ -299,6 +293,60 @@ const SilentSendCrypto = { } }, + /** + * Check if the cached key needs re-verification (TTL expired). + * This does NOT delete the key — it just signals that the user + * should re-verify via WebAuthn/biometric before using it. + */ + async needsReverification() { + try { + const db = await this._openCacheDB(); + return new Promise((resolve) => { + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('syncKey'); + req.onsuccess = () => { + const entry = req.result; + if (!entry) return resolve(false); // no key = nothing to re-verify + if (entry.reverifyAt === -1) return resolve(false); // never + if (entry.reverifyAt === 0) return resolve(true); // each session + resolve(Date.now() >= entry.reverifyAt); + }; + req.onerror = () => resolve(false); + }); + } catch { + return false; + } + }, + + /** + * Mark the cached key as freshly verified (resets the TTL timer). + */ + async markVerified(ttlDays = 90) { + try { + const db = await this._openCacheDB(); + const entry = await new Promise((resolve) => { + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('syncKey'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + }); + if (!entry) return; + + entry.reverifyAt = ttlDays === -1 + ? -1 + : ttlDays === 0 + ? 0 + : Date.now() + ttlDays * 86400000; + + await new Promise((resolve, reject) => { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').put(entry, 'syncKey'); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + } catch { /* non-fatal */ } + }, + async clearCachedKey() { try { const db = await this._openCacheDB(); @@ -312,12 +360,14 @@ const SilentSendCrypto = { }, // ---------------------------------------------------------------- - // WebAuthn — biometric/PIN unlock as re-authentication gate + // WebAuthn — biometric/PIN as PRIMARY re-authentication // - // On first setup, we create a credential tied to this origin. - // On re-auth, we verify the credential — if it passes, we release - // the cached key. WebAuthn doesn't produce an encryption key; - // it gates access to the IndexedDB-cached CryptoKey. + // On first setup (per device), the user enters their password once + // to derive the encryption key. A WebAuthn credential is registered + // on that device. From then on, ALL re-verification uses biometrics + // — the password is never needed again on that device unless + // IndexedDB is cleared. The CryptoKey persists indefinitely in + // IndexedDB; WebAuthn just gates access when TTL expires. // ---------------------------------------------------------------- /** diff --git a/src/lib/sync.js b/src/lib/sync.js index e49489d..088e446 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -47,36 +47,55 @@ const SilentSendSync = { }, /** - * Obtain the encryption key — from cache, WebAuthn, or requires password. - * Returns { key, salt } or null if auth is required. + * Obtain the encryption key for sync operations. * - * The caller should handle null by prompting the user. + * The key persists in IndexedDB indefinitely once the password is + * entered (once per device). Re-verification via WebAuthn/biometric + * is triggered by the TTL timer — but the key is NEVER deleted. + * + * Flow: + * 1. Check cached key — if exists and no re-verification needed, return it + * 2. If re-verification needed and WebAuthn is set up, prompt biometric + * 3. If no cached key at all, password is needed (first time on this device) + * + * Returns { key, salt } or null if password entry is required. */ async _getEncryptionKey() { const config = await this._getSyncEncryption(); if (!config?.enabled) return null; - // Try cached key first const cached = await SilentSendCrypto.getCachedKey(); - if (cached) return cached; - // Try WebAuthn re-auth if configured - if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { - const hasCredential = await SilentSendCrypto.hasWebAuthnCredential(); - if (hasCredential) { - const verified = await SilentSendCrypto.webAuthnAuthenticate(); - if (verified) { - // WebAuthn passed — but we need the actual key. - // The key must have been cached previously (before expiry triggered re-auth). - // If it's gone from cache, we need the password again. - // Check if we stored a wrapped version for WebAuthn recovery. - const wrapped = await this._getWrappedKey(); - if (wrapped) return wrapped; + if (cached) { + // Key exists — check if re-verification is needed + const needsReverify = await SilentSendCrypto.needsReverification(); + + if (!needsReverify) { + return cached; // Key valid, no re-verification needed + } + + // TTL expired — try WebAuthn as primary re-auth + if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { + const hasCredential = await SilentSendCrypto.hasWebAuthnCredential(); + if (hasCredential) { + const verified = await SilentSendCrypto.webAuthnAuthenticate(); + if (verified) { + // Biometric passed — reset the TTL timer and return the key + await SilentSendCrypto.markVerified(config.ttlDays ?? 90); + return cached; + } } } + + // WebAuthn not available or failed — still return the key but + // signal that re-verification is pending (the UI can prompt) + // For sync operations, we allow the key to be used — the data + // is already on this device. Re-verification is a UX gate, not + // a security boundary (the key is in IndexedDB regardless). + return cached; } - // No cached key, no WebAuthn recovery — password needed + // No cached key at all — password needed (first time on this device) return null; }, @@ -119,7 +138,9 @@ const SilentSendSync = { /** * Authenticate with password (+ optional TOTP) and cache the key. - * Called from the UI after prompting the user. + * Called from the UI — typically only needed ONCE per device. + * After this, the key persists in IndexedDB and WebAuthn handles + * any re-verification. * * @param {string} password * @param {string} [totpCode] — required if TOTP is configured @@ -148,13 +169,21 @@ const SilentSendSync = { } } - // Cache the key + // Cache the key persistently (never auto-deletes) const ttlDays = config.ttlDays ?? 90; await SilentSendCrypto.cacheKey(key, salt, ttlDays); - // Store wrapped key for WebAuthn recovery - if (config.webauthn) { - await this._storeWrappedKey(key, salt); + // Store key for WebAuthn-gated access + await this._storeWrappedKey(key, salt); + + // Register WebAuthn credential if enabled and not yet registered + if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { + const hasCred = await SilentSendCrypto.hasWebAuthnCredential(); + if (!hasCred) { + try { + await SilentSendCrypto.webAuthnRegister(); + } catch { /* non-fatal — biometric just won't be available */ } + } } return { success: true }; @@ -233,20 +262,18 @@ const SilentSendSync = { }, /** - * Check if authentication is needed (key cache expired). + * Check if password entry is needed (no cached key on this device). + * This is only true when the device has never been set up — once the + * password is entered, the key persists indefinitely in IndexedDB. + * Re-verification (via WebAuthn) is handled transparently by + * _getEncryptionKey() and doesn't require user interaction here. */ async needsAuth() { const config = await this._getSyncEncryption(); if (!config?.enabled) return false; const cached = await SilentSendCrypto.getCachedKey(); - if (cached) return false; - - // Try WebAuthn silently - if (config.webauthn) { - const wrapped = await this._getWrappedKey(); - if (wrapped) return false; // WebAuthn can recover - } + if (cached) return false; // key exists — WebAuthn handles re-verify return true; }, diff --git a/src/options/options.html b/src/options/options.html index fcc9ea0..9804405 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -162,7 +162,7 @@ - + diff --git a/src/options/options.js b/src/options/options.js index e2ae6a0..8cda4a1 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -869,28 +869,56 @@ async function initSyncEncryptionUI() { } }); - // Auth prompt — Unlock button + // Auth prompt — Unlock with password (first-device or re-verify) $('#btnSyncAuth').addEventListener('click', async () => { const password = $('#syncAuthPassword').value; - const totpCode = $('#syncAuthTOTP').value; + const totpCode = $('#syncAuthTOTPForPassword').value; if (!password) { setSyncAuthStatus('Enter your password.', 'warn'); return; } - const result = await SilentSendSync.authenticate(password, totpCode || undefined); + // 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); + } else { + // First device — full auth with password + TOTP if configured + result = await SilentSendSync.authenticate(password, totpCode || undefined); + } + if (result.success) { $('#syncAuthPrompt').style.display = 'none'; $('#syncAuthPassword').value = ''; - $('#syncAuthTOTP').value = ''; - setSyncEncStatus('Authenticated. Sync data unlocked.', 'ok'); + $('#syncAuthTOTPForPassword').value = ''; + setSyncEncStatus(cached ? 'Re-verified with password.' : 'Authenticated. Sync data unlocked.', 'ok'); } else { setSyncAuthStatus(result.reason, 'error'); } }); - // Auth prompt — Biometric button (primary re-auth after first password entry) + // Re-verify with TOTP alone (key must already exist) + $('#btnSyncAuthTOTP').addEventListener('click', async () => { + const totpCode = $('#syncAuthTOTP').value; + if (!totpCode || totpCode.length < 6) { + setSyncAuthStatus('Enter your 6-digit TOTP code.', 'warn'); + return; + } + + const result = await SilentSendSync.reverifyWithTOTP(totpCode); + if (result.success) { + $('#syncAuthPrompt').style.display = 'none'; + $('#syncAuthTOTP').value = ''; + setSyncEncStatus('Re-verified with TOTP.', 'ok'); + } else { + setSyncAuthStatus(result.reason, 'error'); + } + }); + + // Re-verify with biometric/PIN (key must already exist) $('#btnSyncAuthBiometric').addEventListener('click', async () => { setSyncAuthStatus('Waiting for biometric...', 'neutral'); const verified = await SilentSendCrypto.webAuthnAuthenticate(); @@ -899,9 +927,9 @@ async function initSyncEncryptionUI() { const ttlDays = config?.ttlDays ?? 90; await SilentSendCrypto.markVerified(ttlDays); $('#syncAuthPrompt').style.display = 'none'; - setSyncEncStatus('Verified via biometric.', 'ok'); + setSyncEncStatus('Re-verified via biometric.', 'ok'); } else { - setSyncAuthStatus('Biometric failed. Use password instead.', 'error'); + setSyncAuthStatus('Biometric failed. Try TOTP or password.', 'error'); } }); } @@ -931,6 +959,12 @@ async function showEncryptionConfigured() { const needsAuth = await SilentSendSync.needsAuth(); if (needsAuth) { showSyncAuthPrompt('first-device'); + } else { + // Key exists — check if re-verification is needed + const needsReverify = await SilentSendSync.needsReverification(); + if (needsReverify) { + showSyncAuthPrompt('reverify'); + } } } @@ -944,35 +978,58 @@ function showEncryptionNotConfigured() { /** * Show the auth prompt. * @param {'first-device'|'reverify'|'decrypt'} mode + * + * first-device: No cached key — password (+ TOTP if configured) required. + * reverify: Key exists but TTL expired — any ONE of: biometric / TOTP / password. + * decrypt: Encrypted data arrived — same as first-device if no key, reverify if key exists. */ async function showSyncAuthPrompt(mode = 'decrypt') { const config = await SilentSendSync._getSyncEncryption(); const promptEl = $('#syncAuthPrompt'); promptEl.style.display = 'block'; - // Adjust header message based on context + const isReverify = (mode === 'reverify') || + (mode === 'decrypt' && await SilentSendCrypto.getCachedKey()); + + // Adjust header message const headerEl = promptEl.querySelector('p'); if (mode === 'first-device') { headerEl.textContent = 'First time on this device — enter your sync encryption password'; - } else if (mode === 'reverify') { - headerEl.textContent = 'Re-verification required — use biometric or enter password'; + } else if (isReverify) { + headerEl.textContent = 'Re-verification required — use any method below'; } else { - headerEl.textContent = 'Authentication required — encrypted sync data needs decryption'; + headerEl.textContent = 'First time on this device — enter your sync encryption password'; } - // Show TOTP field if needed - if (config?.totpSecret) { - $('#syncAuthTOTP').style.display = ''; + // First-device: show TOTP alongside password if configured + if (!isReverify && config?.totpSecret) { + $('#syncAuthTOTPForPassword').style.display = ''; } else { - $('#syncAuthTOTP').style.display = 'none'; + $('#syncAuthTOTPForPassword').style.display = 'none'; } - // Show biometric button — available unless this is first-device setup - // (no WebAuthn credential exists yet on a new device) - if (mode !== 'first-device' && config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { - const hasCred = await SilentSendCrypto.hasWebAuthnCredential(); - $('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none'; + // Re-verify alternatives section + const reverifyOpts = $('#reverifyOptions'); + if (isReverify) { + reverifyOpts.style.display = 'block'; + + // Biometric button + if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { + const hasCred = await SilentSendCrypto.hasWebAuthnCredential(); + $('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none'; + } else { + $('#btnSyncAuthBiometric').style.display = 'none'; + } + + // TOTP re-verify option + const totpGroup = $('#totpReverifyGroup'); + if (config?.totpSecret) { + totpGroup.style.display = 'flex'; + } else { + totpGroup.style.display = 'none'; + } } else { + reverifyOpts.style.display = 'none'; $('#btnSyncAuthBiometric').style.display = 'none'; } } From 39e609a14e6c81712e270d535263bb4554597da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 18:44:42 +0000 Subject: [PATCH 3/5] feat: cross-device encryption bootstrap via sync payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encrypted sync data now embeds the encryption config so new devices can self-bootstrap from just the sync data + the password: Outer envelope (plaintext): - salt + verificationBlob — needed to derive key on new device Inner payload (encrypted): - TOTP secret, authMethod, ttlDays, webauthn flag Flow on new device: 1. Pull encrypted sync data from any channel 2. _decryptFromSync detects no local config, bootstraps from _encConfig 3. User enters password → key derived → payload decrypted 4. Full config (including TOTP secret) restored from inner _encMeta 5. Device is now fully configured — WebAuthn can be registered locally https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn --- src/lib/sync.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/lib/sync.js b/src/lib/sync.js index eb3dd21..e2a43af 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -351,7 +351,16 @@ const SilentSendSync = { /** * Encrypt data for sync if encryption is enabled. - * Returns the original data if encryption is not enabled or key unavailable. + * Embeds the encryption config (salt, TOTP secret, etc.) into the + * encrypted payload so a new device can bootstrap itself from just + * the sync data + the password. + * + * Outer envelope (plaintext): { _ssEncrypted, payload, version, lastModified, _encConfig } + * - _encConfig contains salt and verificationBlob (needed to derive key on new device) + * - Everything else (TOTP secret, settings) is inside the encrypted payload + * + * Inner payload (encrypted): { ...syncData, _encMeta } + * - _encMeta contains the full encryption config including TOTP secret */ async _encryptForSync(data) { const config = await this._getSyncEncryption(); @@ -359,17 +368,33 @@ const SilentSendSync = { const keyInfo = await this._getEncryptionKey(); if (!keyInfo) { - // Key not available — caller should prompt for auth return { data: null, encrypted: false, needsAuth: true }; } - const encryptedPayload = await SilentSendCrypto.encryptWithKey(data, keyInfo.key); + // Embed encryption config inside the encrypted payload + // so new devices can bootstrap their local config after decryption + const innerData = { + ...data, + _encMeta: { + authMethod: config.authMethod, + ttlDays: config.ttlDays, + webauthn: config.webauthn, + totpSecret: config.totpSecret || null, + }, + }; + + const encryptedPayload = await SilentSendCrypto.encryptWithKey(innerData, keyInfo.key); return { data: { _ssEncrypted: true, payload: encryptedPayload, version: data.version, lastModified: data.lastModified, + // Plaintext bootstrap info — needed to derive the key on a new device + _encConfig: { + salt: config.salt, + verificationBlob: config.verificationBlob, + }, }, encrypted: true, }; @@ -377,17 +402,50 @@ const SilentSendSync = { /** * Decrypt sync data if it's encrypted. - * Returns the original data if not encrypted. + * On a new device (no local encryption config), uses the _encConfig + * from the sync envelope to bootstrap. After decryption, restores + * the full encryption config from the inner _encMeta. */ async _decryptFromSync(data) { if (!data?._ssEncrypted) return { data, decrypted: false }; + // If this device has no encryption config yet, bootstrap from the sync envelope + let config = await this._getSyncEncryption(); + if (!config?.enabled && data._encConfig) { + // Save minimal config so authenticate() can work + config = { + enabled: true, + salt: data._encConfig.salt, + verificationBlob: data._encConfig.verificationBlob, + authMethod: 'password', // will be updated from _encMeta after decryption + ttlDays: 90, + webauthn: false, + }; + await this._saveSyncEncryption(config); + } + const keyInfo = await this._getEncryptionKey(); if (!keyInfo) { return { data: null, decrypted: false, needsAuth: true }; } const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key); + + // Restore full encryption config from inner metadata + if (decrypted._encMeta) { + const fullConfig = await this._getSyncEncryption(); + if (fullConfig) { + fullConfig.authMethod = decrypted._encMeta.authMethod || fullConfig.authMethod; + fullConfig.ttlDays = decrypted._encMeta.ttlDays ?? fullConfig.ttlDays; + fullConfig.webauthn = decrypted._encMeta.webauthn ?? fullConfig.webauthn; + if (decrypted._encMeta.totpSecret) { + fullConfig.totpSecret = decrypted._encMeta.totpSecret; + } + await this._saveSyncEncryption(fullConfig); + } + delete decrypted._encMeta; + } + return { data: decrypted, decrypted: true }; }, From a22ba549a2fc4c170533bc28b121d583ff2d7a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 19:03:54 +0000 Subject: [PATCH 4/5] 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 @@ + + +