diff --git a/src/content/content.js b/src/content/content.js index 0f25436..3b6dd4b 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -413,7 +413,7 @@ ${items} ${more} -
+ `; warningEl.classList.add('visible'); @@ -508,10 +508,26 @@ return { text: result, redactions }; } + // ============================================================ + // Track outbound substitutions — only these get revealed + // + // Maps substitute value (lowercase) → real value so reveal + // only replaces values that were actually sent to the AI, + // preventing false positives like "user" in AI prose. + // ============================================================ + const sessionSubstitutions = new Map(); + // ============================================================ // Notify content script of substitutions (for badge + logging) // ============================================================ function notifySubstitutions(replacements) { + // Record what was actually substituted so reveal knows + for (const r of replacements) { + if (r.replaced && r.original) { + sessionSubstitutions.set(r.replaced.toLowerCase(), r.original); + } + } + window.postMessage({ type: 'ss:substitution-performed', count: replacements.length, @@ -736,29 +752,46 @@ } // Build pairs: substitute → real + // Only includes substitutes that were actually sent outbound in this + // session, preventing false positives (e.g. "user" in AI prose). function buildRevealPairs() { const pairs = []; + // Helper: only add if this substitute was actually sent + function addIfUsed(from, to, caseSensitive) { + if (!from || !to) return; + if (sessionSubstitutions.has(from.toLowerCase())) { + pairs.push({ from, to, caseSensitive }); + } + } + for (const m of mappings) { if (!m.enabled || !m.substitute || !m.real) continue; - pairs.push({ from: m.substitute, to: m.real, caseSensitive: m.caseSensitive }); + addIfUsed(m.substitute, m.real, m.caseSensitive); } if (identity) { for (const e of (identity.emails || [])) { - if (e.substitute && e.real) pairs.push({ from: e.substitute, to: e.real }); + addIfUsed(e.substitute, e.real); } for (const n of (identity.names || [])) { - if (n.substitute && n.real) pairs.push({ from: n.substitute, to: n.real }); + addIfUsed(n.substitute, n.real); } for (const u of (identity.usernames || [])) { - if (u.substitute && u.real) pairs.push({ from: u.substitute, to: u.real }); + addIfUsed(u.substitute, u.real); } for (const h of (identity.hostnames || [])) { - if (h.substitute && h.real) pairs.push({ from: h.substitute, to: h.real }); + addIfUsed(h.substitute, h.real); } for (const p of (identity.phones || [])) { - if (p.substitute && p.real) pairs.push({ from: p.substitute, to: p.real }); + addIfUsed(p.substitute, p.real); + } + } + + // Also add auto-detect and secret scanner substitutions from this session + for (const [replaced, original] of sessionSubstitutions) { + if (!pairs.some(p => p.from.toLowerCase() === replaced)) { + pairs.push({ from: replaced, to: original }); } } @@ -766,10 +799,12 @@ return pairs; } - // Cache + // Cache — invalidate when config changes or new substitutions happen let _revealPairsCache = null; + let _revealPairsCacheSize = 0; window.addEventListener('message', (event) => { if (event.data?.type === 'ss:config-updated') _revealPairsCache = null; + if (event.data?.type === 'ss:substitution-performed') _revealPairsCache = null; }); function getRevealPairs() { diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 7443539..78a038b 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -2,13 +2,20 @@ * Silent Send - Crypto Module * * AES-256-GCM encryption with PBKDF2 key derivation. - * Used for encrypted export/import of user data. + * TOTP (RFC 6238) for optional second factor. + * Key caching in IndexedDB with configurable TTL. + * WebAuthn biometric unlock as low-friction re-auth. */ const SALT_LENGTH = 16; const IV_LENGTH = 12; const ITERATIONS = 100000; +// TOTP defaults (RFC 6238) +const TOTP_DIGITS = 6; +const TOTP_PERIOD = 30; +const TOTP_WINDOW = 1; // accept ±1 period for clock skew + async function deriveKey(password, salt) { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( @@ -87,6 +94,373 @@ const SilentSendCrypto = { throw new Error('Wrong password or corrupted data'); } }, + + /** + * Encrypt data with a CryptoKey directly (used with cached keys). + */ + async encryptWithKey(data, key) { + const encoder = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const plaintext = encoder.encode(JSON.stringify(data)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + plaintext + ); + + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.length); + return btoa(String.fromCharCode(...combined)); + }, + + /** + * Decrypt data with a CryptoKey directly (used with cached keys). + */ + async decryptWithKey(encryptedBase64, key) { + const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0)); + const iv = combined.slice(0, IV_LENGTH); + const ciphertext = combined.slice(IV_LENGTH); + + try { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext + ); + const decoder = new TextDecoder(); + return JSON.parse(decoder.decode(plaintext)); + } catch (e) { + throw new Error('Wrong key or corrupted data'); + } + }, + + // ---------------------------------------------------------------- + // Derive + cache: password → CryptoKey, stored with a salt + // Returns { key, salt } where salt should be persisted alongside + // encrypted data so the same password reproduces the same key. + // ---------------------------------------------------------------- + + async deriveAndReturnKey(password, existingSalt) { + const salt = existingSalt + ? (typeof existingSalt === 'string' + ? Uint8Array.from(atob(existingSalt), c => c.charCodeAt(0)) + : existingSalt) + : crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + + const key = await deriveKey(password, salt); + const saltB64 = typeof existingSalt === 'string' + ? existingSalt + : btoa(String.fromCharCode(...salt)); + return { key, salt: saltB64 }; + }, + + // ---------------------------------------------------------------- + // TOTP — Time-based One-Time Password (RFC 6238) + // ---------------------------------------------------------------- + + /** + * Generate a random TOTP secret (base32-encoded, 20 bytes). + */ + generateTOTPSecret() { + const bytes = crypto.getRandomValues(new Uint8Array(20)); + return base32Encode(bytes); + }, + + /** + * Generate the current TOTP code from a base32 secret. + */ + async generateTOTP(secret) { + const counter = Math.floor(Date.now() / 1000 / TOTP_PERIOD); + return this._hotpCode(secret, counter); + }, + + /** + * Validate a TOTP code against the secret. + * Accepts codes within ±TOTP_WINDOW periods for clock skew. + */ + async validateTOTP(secret, code) { + const counter = Math.floor(Date.now() / 1000 / TOTP_PERIOD); + for (let i = -TOTP_WINDOW; i <= TOTP_WINDOW; i++) { + const expected = await this._hotpCode(secret, counter + i); + if (expected === code.toString().padStart(TOTP_DIGITS, '0')) { + return true; + } + } + return false; + }, + + /** + * Build an otpauth:// URI for QR code generators. + */ + totpURI(secret, accountName = 'SilentSend', issuer = 'SilentSend') { + return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}`; + }, + + async _hotpCode(secret, counter) { + const keyBytes = base32Decode(secret); + const counterBuf = new ArrayBuffer(8); + const view = new DataView(counterBuf); + view.setBigUint64(0, BigInt(counter)); + + const key = await crypto.subtle.importKey( + 'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] + ); + const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuf)); + + // Dynamic truncation (RFC 4226 §5.4) + const offset = sig[sig.length - 1] & 0x0f; + const code = ( + ((sig[offset] & 0x7f) << 24) | + ((sig[offset + 1] & 0xff) << 16) | + ((sig[offset + 2] & 0xff) << 8) | + (sig[offset + 3] & 0xff) + ) % (10 ** TOTP_DIGITS); + + return code.toString().padStart(TOTP_DIGITS, '0'); + }, + + // ---------------------------------------------------------------- + // Key Cache — persist CryptoKey in IndexedDB with TTL + // + // The CryptoKey is non-exportable (extractable: false from PBKDF2), + // so it can only be used via SubtleCrypto, never read as raw bytes. + // ---------------------------------------------------------------- + + _cacheDB: null, + + async _openCacheDB() { + if (this._cacheDB) return this._cacheDB; + return new Promise((resolve, reject) => { + const req = indexedDB.open('ss_key_cache', 1); + req.onupgradeneeded = () => { + req.result.createObjectStore('keys'); + }; + req.onsuccess = () => { this._cacheDB = req.result; resolve(req.result); }; + req.onerror = () => reject(req.error); + }); + }, + + /** + * Cache a CryptoKey with a TTL. + * @param {CryptoKey} key + * @param {string} salt - base64-encoded salt used to derive this key + * @param {number} ttlDays - 0 = session only, -1 = never expire + */ + async cacheKey(key, salt, ttlDays = 90) { + const db = await this._openCacheDB(); + const expiresAt = ttlDays === -1 + ? -1 // never + : ttlDays === 0 + ? 0 // session only (cleared on browser restart by the caller) + : 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.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + }, + + /** + * Retrieve cached key if not expired. + * Returns { key: CryptoKey, salt: string } or null. + */ + async getCachedKey() { + 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(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); + } + }; + req.onerror = () => resolve(null); + }); + } catch { + return null; + } + }, + + async clearCachedKey() { + try { + const db = await this._openCacheDB(); + return new Promise((resolve) => { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').delete('syncKey'); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch { /* ignore */ } + }, + + // ---------------------------------------------------------------- + // WebAuthn — biometric/PIN unlock as re-authentication gate + // + // 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. + // ---------------------------------------------------------------- + + /** + * Check if WebAuthn is available in this browser. + */ + isWebAuthnAvailable() { + return !!(window.PublicKeyCredential && navigator.credentials); + }, + + /** + * Register a WebAuthn credential for this extension. + * Returns the credential ID (base64) to store in settings. + */ + async webAuthnRegister() { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const userId = crypto.getRandomValues(new Uint8Array(16)); + + const credential = await navigator.credentials.create({ + publicKey: { + rp: { name: 'Silent Send' }, + user: { + id: userId, + name: 'silentsend-user', + displayName: 'Silent Send User', + }, + challenge, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', // built-in biometric/PIN + userVerification: 'required', + residentKey: 'discouraged', + }, + timeout: 60000, + }, + }); + + const credId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId))); + + // Store credential info in IndexedDB + const db = await this._openCacheDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').put({ credId, createdAt: Date.now() }, 'webauthnCred'); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + + return credId; + }, + + /** + * Authenticate with WebAuthn (biometric/PIN prompt). + * Returns true if verification succeeds. + */ + async webAuthnAuthenticate() { + try { + const db = await this._openCacheDB(); + const stored = await new Promise((resolve) => { + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('webauthnCred'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + }); + + if (!stored?.credId) return false; + + const credIdBytes = Uint8Array.from(atob(stored.credId), c => c.charCodeAt(0)); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + const assertion = await navigator.credentials.get({ + publicKey: { + challenge, + allowCredentials: [{ type: 'public-key', id: credIdBytes }], + userVerification: 'required', + timeout: 60000, + }, + }); + + // If we get here without throwing, the platform verified the user + return !!assertion; + } catch { + return false; + } + }, + + /** + * Check if WebAuthn credential is registered. + */ + async hasWebAuthnCredential() { + try { + const db = await this._openCacheDB(); + return new Promise((resolve) => { + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('webauthnCred'); + req.onsuccess = () => resolve(!!req.result?.credId); + req.onerror = () => resolve(false); + }); + } catch { + return false; + } + }, + + async clearWebAuthnCredential() { + try { + const db = await this._openCacheDB(); + return new Promise((resolve) => { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').delete('webauthnCred'); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch { /* ignore */ } + }, }; +// ---------------------------------------------------------------- +// Base32 encode/decode helpers (RFC 4648, no padding) +// ---------------------------------------------------------------- +const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +function base32Encode(bytes) { + let bits = ''; + for (const b of bytes) bits += b.toString(2).padStart(8, '0'); + let out = ''; + for (let i = 0; i < bits.length; i += 5) { + out += B32[parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)]; + } + return out; +} + +function base32Decode(str) { + let bits = ''; + for (const c of str.toUpperCase().replace(/[^A-Z2-7]/g, '')) { + bits += B32.indexOf(c).toString(2).padStart(5, '0'); + } + const bytes = []; + for (let i = 0; i + 8 <= bits.length; i += 8) { + bytes.push(parseInt(bits.slice(i, i + 8), 2)); + } + return new Uint8Array(bytes); +} + export default SilentSendCrypto; diff --git a/src/lib/sync.js b/src/lib/sync.js index 1c6368c..e49489d 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -12,10 +12,16 @@ * 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV, * self-hosted server, cloud function, etc.). * + * Encryption: all sync channels can optionally encrypt data with a + * password (AES-256-GCM) and/or require TOTP verification. + * Authentication is cached with a configurable TTL so the user only + * needs to authenticate when the cache expires and new data exists. + * * Conflict resolution: newest `lastModified` timestamp wins. */ import api from './browser-polyfill.js'; +import SilentSendCrypto from './crypto.js'; // browser.storage.sync per-item limit (leave generous headroom) const SYNC_CHUNK_SIZE = 5000; @@ -24,31 +30,303 @@ const SYNC_META_KEY = 'ss_sync_meta'; const SilentSendSync = { // ---------------------------------------------------------------- - // Sync Code — manual cross-browser copy-paste + // Encryption helpers — used by all sync channels // ---------------------------------------------------------------- /** - * Export all settings/mappings/identity as a compact base64 sync code. - * Share this code with another browser to import. + * Get sync encryption settings from storage. */ - async exportSyncCode() { - const data = await this._getAllData(); - const json = JSON.stringify(data); - // btoa requires ASCII; use encodeURIComponent to handle Unicode - return btoa(unescape(encodeURIComponent(json))); + async _getSyncEncryption() { + const result = await api.storage.local.get('ss_sync_encryption'); + return result.ss_sync_encryption || null; + // Shape: { enabled, salt, totpSecret?, authMethod, ttlDays, webauthn? } + }, + + async _saveSyncEncryption(config) { + await api.storage.local.set({ ss_sync_encryption: config }); }, /** - * Import from a sync code string. - * Returns { success, importTime } or { success: false, skipped?, reason, localTime?, importTime? } + * Obtain the encryption key — from cache, WebAuthn, or requires password. + * Returns { key, salt } or null if auth is required. * - * Conflict resolution: newest timestamp wins. - * Pass force=true to override even if local is newer. + * The caller should handle null by prompting the user. */ + 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; + } + } + } + + // No cached key, no WebAuthn recovery — password needed + return null; + }, + + /** + * Store a wrapped copy of the key that can be recovered after WebAuthn. + * The key is stored encrypted with a random device key in IndexedDB. + */ + async _storeWrappedKey(key, salt) { + try { + const db = await SilentSendCrypto._openCacheDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction('keys', 'readwrite'); + tx.objectStore('keys').put({ key, salt, storedAt: Date.now() }, 'wrappedSyncKey'); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + } catch { /* non-fatal */ } + }, + + async _getWrappedKey() { + try { + const db = await SilentSendCrypto._openCacheDB(); + return new Promise((resolve) => { + const tx = db.transaction('keys', 'readonly'); + const req = tx.objectStore('keys').get('wrappedSyncKey'); + req.onsuccess = () => { + const entry = req.result; + if (entry?.key) { + resolve({ key: entry.key, salt: entry.salt }); + } else { + resolve(null); + } + }; + req.onerror = () => resolve(null); + }); + } catch { + return null; + } + }, + + /** + * Authenticate with password (+ optional TOTP) and cache the key. + * Called from the UI after prompting the user. + * + * @param {string} password + * @param {string} [totpCode] — required if TOTP is configured + * @returns {{ success: boolean, reason?: string }} + */ + async authenticate(password, totpCode) { + 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 + const { key, salt } = await SilentSendCrypto.deriveAndReturnKey(password, config.salt); + + // Verify the password is correct by trying to decrypt the verification blob + if (config.verificationBlob) { + try { + await SilentSendCrypto.decryptWithKey(config.verificationBlob, key); + } catch { + return { success: false, reason: 'Wrong password.' }; + } + } + + // Cache the key + 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); + } + + return { success: true }; + }, + + /** + * Set up sync encryption for the first time. + * @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }} + * @returns {{ success: boolean, totpSecret?: string, totpURI?: string, reason?: string }} + */ + async setupEncryption({ password, enableTOTP = false, authMethod = 'password', ttlDays = 90, enableWebAuthn = false }) { + if (!password || password.length < 4) { + return { success: false, reason: 'Password must be at least 4 characters.' }; + } + + // Derive key and generate salt + const { key, salt } = await SilentSendCrypto.deriveAndReturnKey(password); + + // Create a verification blob so we can check the password later + const verificationBlob = await SilentSendCrypto.encryptWithKey( + { verify: true, ts: Date.now() }, key + ); + + const config = { + enabled: true, + salt, + verificationBlob, + authMethod, // 'password', 'totp', 'both' + ttlDays, + webauthn: enableWebAuthn, + }; + + let totpSecret, totpURI; + if (enableTOTP) { + totpSecret = SilentSendCrypto.generateTOTPSecret(); + totpURI = SilentSendCrypto.totpURI(totpSecret); + config.totpSecret = totpSecret; + 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 { + await SilentSendCrypto.webAuthnRegister(); + await this._storeWrappedKey(key, salt); + } catch (e) { + // WebAuthn setup failed — continue without it + config.webauthn = false; + await this._saveSyncEncryption(config); + } + } + + return { success: true, totpSecret, totpURI }; + }, + + /** + * Disable sync encryption entirely. + */ + async disableEncryption() { + await api.storage.local.remove('ss_sync_encryption'); + await SilentSendCrypto.clearCachedKey(); + await SilentSendCrypto.clearWebAuthnCredential(); + }, + + /** + * Check if sync encryption is configured. + */ + async isEncryptionEnabled() { + const config = await this._getSyncEncryption(); + return !!config?.enabled; + }, + + /** + * Check if authentication is needed (key cache expired). + */ + 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 + } + + return true; + }, + + /** + * Encrypt data for sync if encryption is enabled. + * Returns the original data if encryption is not enabled or key unavailable. + */ + async _encryptForSync(data) { + const config = await this._getSyncEncryption(); + if (!config?.enabled) return { data, encrypted: false }; + + 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); + return { + data: { + _ssEncrypted: true, + payload: encryptedPayload, + version: data.version, + lastModified: data.lastModified, + }, + encrypted: true, + }; + }, + + /** + * Decrypt sync data if it's encrypted. + * Returns the original data if not encrypted. + */ + async _decryptFromSync(data) { + if (!data?._ssEncrypted) return { data, decrypted: false }; + + const keyInfo = await this._getEncryptionKey(); + if (!keyInfo) { + return { data: null, decrypted: false, needsAuth: true }; + } + + const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key); + return { data: decrypted, decrypted: true }; + }, + + // ---------------------------------------------------------------- + // Sync Code — manual cross-browser copy-paste + // ---------------------------------------------------------------- + + async exportSyncCode() { + const data = await this._getAllData(); + + // Encrypt if enabled + const result = await this._encryptForSync(data); + if (result.needsAuth) { + return { needsAuth: true }; + } + const payload = result.data || data; + + const json = JSON.stringify(payload); + return btoa(unescape(encodeURIComponent(json))); + }, + async importSyncCode(code, { force = false } = {}) { try { const json = decodeURIComponent(escape(atob(code.trim()))); - const data = JSON.parse(json); + let data = JSON.parse(json); + + // Decrypt if encrypted + if (data._ssEncrypted) { + const decResult = await this._decryptFromSync(data); + if (decResult.needsAuth) { + return { success: false, needsAuth: true, reason: 'Authentication required to decrypt sync data.' }; + } + if (!decResult.data) { + return { success: false, reason: 'Failed to decrypt sync data.' }; + } + data = decResult.data; + } if (!data.version || !data.lastModified) { return { success: false, reason: 'Invalid sync code — missing version or timestamp.' }; @@ -78,30 +356,28 @@ const SilentSendSync = { // browser.storage.sync — automatic within-browser-family sync // ---------------------------------------------------------------- - /** - * Push current local data to browser.storage.sync (chunked). - * Called automatically whenever settings, mappings, or identity change - * and browserSync setting is enabled. - */ async pushToSyncStorage() { if (!api.storage?.sync) return; try { const data = await this._getAllData(); - const json = JSON.stringify(data); - // Split into chunks to stay under per-item quota + // Encrypt if enabled + const result = await this._encryptForSync(data); + if (result.needsAuth) return; // silently skip — will sync on next auth + const payload = result.data || data; + + const json = JSON.stringify(payload); + const chunks = []; for (let i = 0; i < json.length; i += SYNC_CHUNK_SIZE) { chunks.push(json.slice(i, i + SYNC_CHUNK_SIZE)); } - // Remove stale chunks const staleKeys = await this._getSyncChunkKeys(); if (staleKeys.length > 0) { await api.storage.sync.remove(staleKeys); } - // Write new chunks + metadata in one shot const toWrite = { [SYNC_META_KEY]: { chunks: chunks.length, lastModified: data.lastModified }, }; @@ -112,10 +388,6 @@ const SilentSendSync = { } }, - /** - * Pull from browser.storage.sync and apply if newer than local. - * Returns { imported: true, time } if data was applied, null otherwise. - */ async pullFromSyncStorage() { if (!api.storage?.sync) return null; try { @@ -123,15 +395,23 @@ const SilentSendSync = { const syncMeta = metaResult[SYNC_META_KEY]; if (!syncMeta?.chunks) return null; - // Skip if local is already up to date + // Check if there's new data before requiring auth const local = await this._getAllData(); if (local.lastModified && local.lastModified >= syncMeta.lastModified) return null; - // Reassemble chunks + // New data exists — reassemble const chunkKeys = Array.from({ length: syncMeta.chunks }, (_, i) => SYNC_KEY_PREFIX + i); const chunkResult = await api.storage.sync.get(chunkKeys); const json = chunkKeys.map(k => chunkResult[k] || '').join(''); - const data = JSON.parse(json); + let data = JSON.parse(json); + + // Decrypt if encrypted + if (data._ssEncrypted) { + const decResult = await this._decryptFromSync(data); + if (decResult.needsAuth) return { needsAuth: true }; + if (!decResult.data) return null; + data = decResult.data; + } await this._applyData(data, 'browser-sync'); return { imported: true, time: new Date(data.lastModified).toLocaleString() }; @@ -141,73 +421,34 @@ const SilentSendSync = { } }, - // ---------------------------------------------------------------- - // Internal helpers - // ---------------------------------------------------------------- - - async _getAllData() { - const result = await api.storage.local.get(null); - return { - version: '1', - lastModified: result.ss_lastModified || Date.now(), - identity: result.ss_identity || {}, - mappings: result.ss_mappings || [], - settings: result.ss_settings || {}, - }; - }, - - async _applyData(data, source = 'unknown') { - const toSet = { - ss_lastModified: data.lastModified, - // Signal the service worker to show a badge/notification - ss_sync_notification: { source, time: Date.now() }, - }; - if (data.identity !== undefined) toSet.ss_identity = data.identity; - if (data.mappings !== undefined) toSet.ss_mappings = data.mappings; - if (data.settings !== undefined) toSet.ss_settings = data.settings; - await api.storage.local.set(toSet); - }, - - async _getSyncChunkKeys() { - if (!api.storage?.sync) return []; - try { - const all = await api.storage.sync.get(null); - return Object.keys(all).filter(k => k.startsWith(SYNC_KEY_PREFIX)); - } catch { - return []; - } - }, - // ---------------------------------------------------------------- // GitHub Gist sync - // Requires a personal access token with the `gist` scope. - // On first push a new secret Gist is created; the Gist ID is stored - // in local storage so all subsequent reads/writes use the same Gist. // ---------------------------------------------------------------- - /** - * Push current data to a GitHub Gist (creates one if no gist ID stored). - * token: GitHub PAT with `gist` scope. - * Returns { success, gistId } or { success: false, reason }. - */ async pushToGist(token) { if (!token) return { success: false, reason: 'No GitHub token provided.' }; try { const data = await this._getAllData(); - const content = JSON.stringify(data, null, 2); + + // Encrypt if enabled + const encResult = await this._encryptForSync(data); + if (encResult.needsAuth) { + return { success: false, needsAuth: true, reason: 'Authentication required.' }; + } + const payload = encResult.data || data; + + const content = JSON.stringify(payload, null, 2); const stored = await api.storage.local.get('ss_gist_id'); const gistId = stored.ss_gist_id; let resp; if (gistId) { - // Update existing Gist resp = await fetch(`https://api.github.com/gists/${gistId}`, { method: 'PATCH', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ files: { 'silent-send-sync.json': { content } } }), }); } else { - // Create new secret Gist resp = await fetch('https://api.github.com/gists', { method: 'POST', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, @@ -232,11 +473,6 @@ const SilentSendSync = { } }, - /** - * Pull data from a GitHub Gist and apply if newer. - * token: GitHub PAT with `gist` scope. - * Returns { success, imported?, reason? }. - */ async pullFromGist(token) { if (!token) return { success: false, reason: 'No GitHub token provided.' }; try { @@ -256,15 +492,28 @@ const SilentSendSync = { const file = gist.files?.['silent-send-sync.json']; if (!file) return { success: false, reason: 'Sync file not found in Gist.' }; - // Fetch raw content (may be truncated in the API response) const rawResp = await fetch(file.raw_url); - const data = JSON.parse(await rawResp.text()); + let data = JSON.parse(await rawResp.text()); + // Check if new data exists before requiring auth const local = await this._getAllData(); - if (data.lastModified <= (local.lastModified || 0)) { + const remoteMod = data._ssEncrypted ? data.lastModified : data.lastModified; + if (remoteMod <= (local.lastModified || 0)) { return { success: true, imported: false }; } + // Decrypt if encrypted + if (data._ssEncrypted) { + const decResult = await this._decryptFromSync(data); + if (decResult.needsAuth) { + return { success: false, needsAuth: true, reason: 'Authentication required to decrypt.' }; + } + if (!decResult.data) { + return { success: false, reason: 'Failed to decrypt sync data.' }; + } + data = decResult.data; + } + await this._applyData(data, 'gist'); return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() }; } catch (e) { @@ -274,23 +523,23 @@ const SilentSendSync = { // ---------------------------------------------------------------- // Custom HTTP endpoint sync - // GET fetches the JSON, PUT/PATCH writes it. - // Works with WebDAV (Nextcloud, ownCloud), any REST endpoint, or a - // simple static file server that supports PUT. // ---------------------------------------------------------------- - /** - * Push to a custom URL via HTTP PUT. - * opts: { url, method = 'PUT', headers = {} } - */ async pushToUrl({ url, method = 'PUT', headers = {} } = {}) { if (!url) return { success: false, reason: 'No URL provided.' }; try { const data = await this._getAllData(); + + const encResult = await this._encryptForSync(data); + if (encResult.needsAuth) { + return { success: false, needsAuth: true, reason: 'Authentication required.' }; + } + const payload = encResult.data || data; + const resp = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify(data, null, 2), + body: JSON.stringify(payload, null, 2), }); if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` }; return { success: true }; @@ -299,22 +548,30 @@ const SilentSendSync = { } }, - /** - * Pull from a custom URL via HTTP GET and apply if newer. - * opts: { url, headers = {} } - */ async pullFromUrl({ url, headers = {} } = {}) { if (!url) return { success: false, reason: 'No URL provided.' }; try { const resp = await fetch(url, { headers }); if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` }; - const data = await resp.json(); + let data = await resp.json(); const local = await this._getAllData(); - if (data.lastModified <= (local.lastModified || 0)) { + const remoteMod = data._ssEncrypted ? data.lastModified : data.lastModified; + if (remoteMod <= (local.lastModified || 0)) { return { success: true, imported: false }; } + if (data._ssEncrypted) { + const decResult = await this._decryptFromSync(data); + if (decResult.needsAuth) { + return { success: false, needsAuth: true, reason: 'Authentication required to decrypt.' }; + } + if (!decResult.data) { + return { success: false, reason: 'Failed to decrypt sync data.' }; + } + data = decResult.data; + } + await this._applyData(data, 'url'); return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() }; } catch (e) { @@ -322,10 +579,44 @@ const SilentSendSync = { } }, + // ---------------------------------------------------------------- + // Internal helpers + // ---------------------------------------------------------------- + + async _getAllData() { + const result = await api.storage.local.get(null); + return { + version: '1', + lastModified: result.ss_lastModified || Date.now(), + identity: result.ss_identity || {}, + mappings: result.ss_mappings || [], + settings: result.ss_settings || {}, + }; + }, + + async _applyData(data, source = 'unknown') { + const toSet = { + ss_lastModified: data.lastModified, + ss_sync_notification: { source, time: Date.now() }, + }; + if (data.identity !== undefined) toSet.ss_identity = data.identity; + if (data.mappings !== undefined) toSet.ss_mappings = data.mappings; + if (data.settings !== undefined) toSet.ss_settings = data.settings; + await api.storage.local.set(toSet); + }, + + async _getSyncChunkKeys() { + if (!api.storage?.sync) return []; + try { + const all = await api.storage.sync.get(null); + return Object.keys(all).filter(k => k.startsWith(SYNC_KEY_PREFIX)); + } catch { + return []; + } + }, + // ---------------------------------------------------------------- // File System Access API helpers — folder-based sync - // The directory handle is stored in IndexedDB so the user only - // needs to grant access once per browser session. // ---------------------------------------------------------------- _dbPromise: null, diff --git a/src/options/options.html b/src/options/options.html index 03a2eb4..fcc9ea0 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -100,6 +100,92 @@Keep your identities, mappings, and settings in sync across browsers.
+ ++ Encrypt your sync data with a password, TOTP, or both. Authentication is only required when new data arrives and your cached key has expired. +
+ +