diff --git a/README.md b/README.md index 0695740..d4cce7c 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,14 @@ You can create multiple profiles (Personal, Work, Spouse) using the dropdown at ### Icon colors -| Icon color | Meaning | -|-----------|---------| -| **Gray** | Not configured — does nothing until you set up your identity | -| **Black** | Active and protecting | -| **Blue** | Reveal mode on — showing your real data in AI responses | -| **Red** | Manually disabled | +| Icon color | Badge | Meaning | +|-----------|-------|---------| +| **Gray** | | Not configured — does nothing until you set up your identity | +| **Black** | | Active and protecting | +| **Blue** | | Reveal mode on — showing your real data in AI responses | +| **Red** | | Manually disabled | +| Any | **LOCK** (red) | Vault locked — encrypted data, needs password to unlock | +| Any | **SYN** (purple) | New settings synced from another device | ### Reveal mode @@ -261,17 +263,69 @@ src/ substitution-engine.js — Core explicit find/replace logic smart-patterns.js — Auto-detection of emails, names, usernames, hostnames, phones, paths secret-scanner.js — Auto-detection of API keys, tokens, passwords, SSNs, credit cards - storage.js — Browser storage wrapper + crypto.js — AES-256-GCM encryption, PBKDF2 key derivation, TOTP (RFC 6238), WebAuthn, key caching + sync.js — Cross-browser sync with encryption (browser sync, Gist, folder, URL, sync codes) + storage.js — Browser storage wrapper with transparent at-rest encryption browser-polyfill.js — Chrome/Firefox API compatibility ``` -## Privacy +## Privacy & Security -- All data stays local in browser storage -- No external servers, no telemetry, no analytics +- All data stays local in browser storage — no external servers, no telemetry, no analytics - The extension only activates on supported AI sites (and any custom domains you add) - Your real identity data never leaves your machine +### At-rest encryption + +When you enable **sync encryption** (Options → Sync Between Browsers → Sync Encryption), all sensitive data is AES-256-GCM encrypted before being written to browser storage: + +| What's encrypted | Contains | +|---|---| +| Identity profiles | Real names, emails, usernames, hostnames, phones + substitutes | +| Mappings | All real → substitute pairs | +| Activity log | History of what was substituted | +| Settings | Custom domains, configuration preferences | +| TOTP secret | Authenticator app shared secret | +| All sync data | Everything sent to Gist, sync folders, custom URLs, browser sync | + +**Only two things remain plaintext** — the encryption salt (needed to derive the key) and a verification blob (needed to check the password). Neither contains PPI. + +Without at-rest encryption, data is stored in plaintext in the browser's local storage (similar to cookies and localStorage). Anyone with file system access to your browser profile directory could read it. + +### Vault unlock + +After a browser restart, the extension is in a **locked** state: + +1. Badge shows **LOCK** in red — substitutions are paused +2. Click the extension icon to see the unlock prompt +3. Enter your password (first time per device), or use biometric/TOTP for re-verification +4. Protection resumes immediately across all open tabs + +This is similar to how password managers work — your vault is locked until you authenticate. + +### Authentication options + +| Method | When it's used | +|---|---| +| **Password** | Required once per device to derive the encryption key. The key is then cached indefinitely in IndexedDB. | +| **TOTP** | Optional second factor alongside password. Can also be used alone for re-verification after the key is cached. | +| **WebAuthn (biometric/PIN)** | Primary re-verification method after first setup. Uses fingerprint, Face ID, or Windows Hello. | + +Re-verification (biometric, TOTP, or password) is only triggered when the configurable TTL expires (default: 90 days) **and** new sync data exists. If nothing changed, you're never prompted. + +### Cross-device sync encryption + +All sync channels (browser sync, GitHub Gist, folder sync, custom URL, sync codes) encrypt data before sending. A new device bootstraps itself from the encrypted sync payload: + +1. Pull encrypted data from any sync channel +2. Enter the same password used on the original device (once) +3. Full configuration (including TOTP secret) is restored from the encrypted payload +4. WebAuthn credential is registered locally for future re-verification + +### 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. + ## Disclaimer **Silent Send is not a battle-tested privacy application.** It is a convenience tool that reduces the chance of accidentally sharing personal information with AI services. It should not be relied upon as your sole privacy protection. 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/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/storage.js b/src/lib/storage.js index a0ed709..b4d3ab3 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,13 @@ const KEYS = { SETTINGS: 'ss_settings', }; +// Keys that contain sensitive PPI and should be encrypted at rest +// All user data keys are encrypted at rest — settings included since +// custom domains and configuration can reveal what services the user +// accesses. Only ss_sync_encryption (salt, verification blob) and +// ss_lastModified stay plaintext for bootstrap purposes. +const ENCRYPTED_KEYS = new Set([KEYS.MAPPINGS, KEYS.IDENTITY, KEYS.LOG, KEYS.SETTINGS]); + const DEFAULT_SETTINGS = { enabled: true, showHighlights: false, @@ -29,15 +45,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 +219,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 +302,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,26 +321,30 @@ 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 encrypted at rest (custom domains can reveal which + // private AI services the user accesses). When locked, defaults are + // returned so the extension can show basic UI. async getSettings() { - const result = await api.storage.local.get(KEYS.SETTINGS); - return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) }; + const data = await this._readSecure(KEYS.SETTINGS); + return { ...DEFAULT_SETTINGS, ...(data || {}) }; }, async saveSettings(settings) { const current = await this.getSettings(); - await api.storage.local.set({ - [KEYS.SETTINGS]: { ...current, ...settings }, - ss_lastModified: Date.now(), - }); + await this._writeSecure( + KEYS.SETTINGS, + { ...current, ...settings }, + { ss_lastModified: Date.now() }, + ); }, }; diff --git a/src/lib/sync.js b/src/lib/sync.js index e49489d..46b00ac 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -43,40 +43,88 @@ 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 }); }, /** - * Obtain the encryption key — from cache, WebAuthn, or requires password. - * Returns { key, salt } or null if auth is required. + * 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; + } + }, + + /** + * 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 +167,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 re-verification + * can use WebAuthn or TOTP alone. * * @param {string} password * @param {string} [totpCode] — required if TOTP is configured @@ -129,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); @@ -148,18 +191,117 @@ const SilentSendSync = { } } - // Cache the key + // 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); - // 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 }; }, + /** + * Re-verify identity using TOTP code alone (no password needed). + * Only works when the key is already cached (not first-device setup). + * Resets the TTL timer on success. + * + * @param {string} totpCode — 6-digit TOTP code + * @returns {{ success: boolean, reason?: string }} + */ + async reverifyWithTOTP(totpCode) { + const config = await this._getSyncEncryption(); + if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' }; + + 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.' }; + + // 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 + await SilentSendCrypto.markVerified(config.ttlDays ?? 90); + return { success: true }; + }, + + /** + * Re-verify identity using password alone (no TOTP needed). + * Only works when the key is already cached (not first-device setup). + * Resets the TTL timer on success. + * + * @param {string} password + * @returns {{ success: boolean, reason?: string }} + */ + async reverifyWithPassword(password) { + const config = await this._getSyncEncryption(); + if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' }; + + // Must have a cached key + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return { success: false, reason: 'No cached key. Full authentication required.' }; + + // Verify password against the verification blob + const { key } = await SilentSendCrypto.deriveAndReturnKey(password, config.salt); + if (config.verificationBlob) { + try { + await SilentSendCrypto.decryptWithKey(config.verificationBlob, key); + } catch { + return { success: false, reason: 'Wrong password.' }; + } + } + + // Reset the TTL timer + await SilentSendCrypto.markVerified(config.ttlDays ?? 90); + return { success: true }; + }, + + /** + * Check if re-verification is needed (TTL expired but key exists). + * Different from needsAuth() which checks if the key is missing entirely. + */ + async needsReverification() { + const config = await this._getSyncEncryption(); + if (!config?.enabled) return false; + + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return false; // no key = needs full auth, not re-verify + + return SilentSendCrypto.needsReverification(); + }, + /** * Set up sync encryption for the first time. * @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }} @@ -187,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 { @@ -212,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 }; }, @@ -219,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(); @@ -233,27 +384,34 @@ 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; }, /** * 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(); @@ -261,17 +419,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: await this._getTOTPSecret(config) || 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, }; @@ -279,17 +453,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 }; }, diff --git a/src/options/options.html b/src/options/options.html index fcc9ea0..39a0f8d 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -160,16 +160,27 @@ - +
diff --git a/src/options/options.js b/src/options/options.js index 4fb795d..49931a1 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -869,45 +869,67 @@ 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 + // 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(); if (verified) { - // Try to recover wrapped key - const wrapped = await SilentSendSync._getWrappedKey(); - if (wrapped) { - const config = await SilentSendSync._getSyncEncryption(); - const ttlDays = config?.ttlDays ?? 90; - await SilentSendCrypto.cacheKey(wrapped.key, wrapped.salt, ttlDays); - $('#syncAuthPrompt').style.display = 'none'; - setSyncEncStatus('Authenticated via biometric. Sync data unlocked.', 'ok'); - } else { - setSyncAuthStatus('Biometric verified but key not found. Enter password.', 'warn'); - } + const config = await SilentSendSync._getSyncEncryption(); + const ttlDays = config?.ttlDays ?? 90; + await SilentSendCrypto.markVerified(ttlDays); + $('#syncAuthPrompt').style.display = 'none'; + setSyncEncStatus('Re-verified via biometric.', 'ok'); } else { - setSyncAuthStatus('Biometric verification failed.', 'error'); + setSyncAuthStatus('Biometric failed. Try TOTP or password.', 'error'); } }); } @@ -923,20 +945,26 @@ async function showEncryptionConfigured() { else if (config.authMethod === 'totp') parts.push('TOTP'); else parts.push('Password'); - if (config.webauthn) parts.push('Biometric'); + if (config.webauthn) parts.push('Biometric re-auth'); - const ttl = config.ttlDays === -1 ? 'never re-auth' - : config.ttlDays === 0 ? 'each session' - : `every ${config.ttlDays}d`; + const ttl = config.ttlDays === -1 ? 'never re-verify' + : config.ttlDays === 0 ? 're-verify each session' + : `re-verify every ${config.ttlDays}d`; parts.push(ttl); $('#encryptionInfo').textContent = `(${parts.join(' · ')})`; } - // Check if auth is currently needed + // Check if password entry is needed (first time on this device) const needsAuth = await SilentSendSync.needsAuth(); if (needsAuth) { - showSyncAuthPrompt(); + showSyncAuthPrompt('first-device'); + } else { + // Key exists — check if re-verification is needed + const needsReverify = await SilentSendSync.needsReverification(); + if (needsReverify) { + showSyncAuthPrompt('reverify'); + } } } @@ -947,22 +975,62 @@ function showEncryptionNotConfigured() { $('#totpSetupResult').style.display = 'none'; } -async function showSyncAuthPrompt() { +/** + * 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(); - $('#syncAuthPrompt').style.display = 'block'; + const promptEl = $('#syncAuthPrompt'); + promptEl.style.display = 'block'; - // Show TOTP field if needed - if (config?.totpSecret) { - $('#syncAuthTOTP').style.display = ''; + 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 (isReverify) { + headerEl.textContent = 'Re-verification required — use any method below'; } else { - $('#syncAuthTOTP').style.display = 'none'; + headerEl.textContent = 'First time on this device — enter your sync encryption password'; } - // Show biometric button if available - if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { - const hasCred = await SilentSendCrypto.hasWebAuthnCredential(); - $('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none'; + // First-device: show TOTP alongside password if configured + const hasTOTP = config?.totpSecret || config?._totpEncrypted; + if (!isReverify && hasTOTP) { + $('#syncAuthTOTPForPassword').style.display = ''; } else { + $('#syncAuthTOTPForPassword').style.display = '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 (hasTOTP) { + totpGroup.style.display = 'flex'; + } else { + totpGroup.style.display = 'none'; + } + } else { + reverifyOpts.style.display = 'none'; $('#btnSyncAuthBiometric').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 @@ + + +