feat: encrypted sync with password/TOTP/WebAuthn + smart reveal

Sync encryption:
- AES-256-GCM encryption for all sync channels (browser sync, gist,
  custom URL, folder sync, sync codes)
- Password with optional TOTP (RFC 6238) second factor
- Configurable auth TTL: session, 30/90/180/365 days, or never
- CryptoKey cached in IndexedDB — auth only needed when cache expires
  AND new data exists (lastModified check runs before auth prompt)
- WebAuthn (biometric/PIN) as low-friction re-authentication gate
- Full options UI for setup, password change, and inline auth prompt

Smart reveal:
- Track which substitute values were actually sent outbound per session
- Reveal mode only replaces values that were genuinely substituted,
  preventing false positives (e.g. AI using the word "user" won't be
  replaced with a real username that maps to "user")

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 18:08:21 +00:00
parent 8eee6aa922
commit 8198620b8a
5 changed files with 1194 additions and 136 deletions
+375 -1
View File
@@ -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;
+392 -101
View File
@@ -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,