/** * Silent Send - Cross-Browser Settings Sync * * Sync mechanisms: * * 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste. * 2. browser.storage.sync — automatic within the same browser family. * 3. Folder sync (File System Access API) — any locally-mounted folder * including Dropbox, OneDrive, Google Drive, iCloud, Nextcloud, etc. * 4. GitHub Gist — serverless cloud sync using a personal access token; * works across any browser/device without a local desktop client. * 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; const SYNC_KEY_PREFIX = 'ss_sync_chunk_'; const SYNC_META_KEY = 'ss_sync_meta'; const SilentSendSync = { // ---------------------------------------------------------------- // Encryption helpers — used by all sync channels // ---------------------------------------------------------------- /** * Get sync encryption settings from storage. */ 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 }); }, /** * Obtain the encryption key for sync operations. * * 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; const cached = await SilentSendCrypto.getCachedKey(); 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 at all — password needed (first time on this device) 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 — 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 * @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 persistently (never auto-deletes) const ttlDays = config.ttlDays ?? 90; await SilentSendCrypto.cacheKey(key, salt, ttlDays); // 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 }; }, /** * 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 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; // 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. */ 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()))); 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.' }; } if (!force) { const local = await this._getAllData(); if (local.lastModified && local.lastModified >= data.lastModified) { return { success: false, skipped: true, reason: 'Local data is the same age or newer.', localTime: new Date(local.lastModified).toLocaleString(), importTime: new Date(data.lastModified).toLocaleString(), }; } } await this._applyData(data, 'code'); return { success: true, importTime: new Date(data.lastModified).toLocaleString() }; } catch (e) { return { success: false, reason: e.message }; } }, // ---------------------------------------------------------------- // browser.storage.sync — automatic within-browser-family sync // ---------------------------------------------------------------- async pushToSyncStorage() { if (!api.storage?.sync) return; try { const data = await this._getAllData(); // 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)); } const staleKeys = await this._getSyncChunkKeys(); if (staleKeys.length > 0) { await api.storage.sync.remove(staleKeys); } const toWrite = { [SYNC_META_KEY]: { chunks: chunks.length, lastModified: data.lastModified }, }; chunks.forEach((chunk, i) => { toWrite[SYNC_KEY_PREFIX + i] = chunk; }); await api.storage.sync.set(toWrite); } catch (e) { console.warn('[Silent Send] pushToSyncStorage failed:', e); } }, async pullFromSyncStorage() { if (!api.storage?.sync) return null; try { const metaResult = await api.storage.sync.get(SYNC_META_KEY); const syncMeta = metaResult[SYNC_META_KEY]; if (!syncMeta?.chunks) return null; // Check if there's new data before requiring auth const local = await this._getAllData(); if (local.lastModified && local.lastModified >= syncMeta.lastModified) return null; // 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(''); 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() }; } catch (e) { console.warn('[Silent Send] pullFromSyncStorage failed:', e); return null; } }, // ---------------------------------------------------------------- // GitHub Gist sync // ---------------------------------------------------------------- async pushToGist(token) { if (!token) return { success: false, reason: 'No GitHub token provided.' }; try { const data = await this._getAllData(); // 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) { 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 { resp = await fetch('https://api.github.com/gists', { method: 'POST', headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'Silent Send settings sync', public: false, files: { 'silent-send-sync.json': { content } }, }), }); } if (!resp.ok) { const err = await resp.json().catch(() => ({})); return { success: false, reason: err.message || `HTTP ${resp.status}` }; } const json = await resp.json(); await api.storage.local.set({ ss_gist_id: json.id }); return { success: true, gistId: json.id }; } catch (e) { return { success: false, reason: e.message }; } }, async pullFromGist(token) { if (!token) return { success: false, reason: 'No GitHub token provided.' }; try { const stored = await api.storage.local.get('ss_gist_id'); const gistId = stored.ss_gist_id; if (!gistId) return { success: false, reason: 'No Gist ID stored. Push first.' }; const resp = await fetch(`https://api.github.com/gists/${gistId}`, { headers: { Authorization: `token ${token}` }, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); return { success: false, reason: err.message || `HTTP ${resp.status}` }; } const gist = await resp.json(); const file = gist.files?.['silent-send-sync.json']; if (!file) return { success: false, reason: 'Sync file not found in Gist.' }; const rawResp = await fetch(file.raw_url); let data = JSON.parse(await rawResp.text()); // Check if new data exists before requiring auth const local = await this._getAllData(); 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) { return { success: false, reason: e.message }; } }, // ---------------------------------------------------------------- // Custom HTTP endpoint sync // ---------------------------------------------------------------- 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(payload, null, 2), }); if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` }; return { success: true }; } catch (e) { return { success: false, reason: e.message }; } }, 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}` }; let data = await resp.json(); const local = await this._getAllData(); 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) { return { success: false, reason: e.message }; } }, // ---------------------------------------------------------------- // 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 // ---------------------------------------------------------------- _dbPromise: null, _openDB() { if (this._dbPromise) return this._dbPromise; this._dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open('ss_sync_handles', 1); req.onupgradeneeded = () => req.result.createObjectStore('handles'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return this._dbPromise; }, async saveSyncDirHandle(handle) { const db = await this._openDB(); return new Promise((resolve, reject) => { const tx = db.transaction('handles', 'readwrite'); tx.objectStore('handles').put(handle, 'syncDir'); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); }, async loadSyncDirHandle() { try { const db = await this._openDB(); return new Promise((resolve) => { const tx = db.transaction('handles', 'readonly'); const req = tx.objectStore('handles').get('syncDir'); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); }); } catch { return null; } }, async clearSyncDirHandle() { try { const db = await this._openDB(); return new Promise((resolve) => { const tx = db.transaction('handles', 'readwrite'); tx.objectStore('handles').delete('syncDir'); tx.oncomplete = resolve; tx.onerror = resolve; }); } catch { /* ignore */ } }, }; export default SilentSendSync;