Merge pull request #7 from outis1one/claude/read-repo-wA3y1
feat: encrypted sync with password/TOTP/WebAuthn + smart reveal
This commit is contained in:
+43
-8
@@ -413,7 +413,7 @@
|
|||||||
</div>
|
</div>
|
||||||
${items}
|
${items}
|
||||||
${more}
|
${more}
|
||||||
<div class="ss-ad-footer">These were sent as-is. Consider adding them to your identity or mappings.</div>
|
<div class="ss-ad-footer">${settings.autoRedactDetected !== false ? 'Auto-redacted before sending.' : 'These were sent as-is.'} Consider adding them to your identity or mappings.</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
warningEl.classList.add('visible');
|
warningEl.classList.add('visible');
|
||||||
@@ -508,10 +508,26 @@
|
|||||||
return { text: result, redactions };
|
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)
|
// Notify content script of substitutions (for badge + logging)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
function notifySubstitutions(replacements) {
|
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({
|
window.postMessage({
|
||||||
type: 'ss:substitution-performed',
|
type: 'ss:substitution-performed',
|
||||||
count: replacements.length,
|
count: replacements.length,
|
||||||
@@ -736,29 +752,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build pairs: substitute → real
|
// 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() {
|
function buildRevealPairs() {
|
||||||
const pairs = [];
|
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) {
|
for (const m of mappings) {
|
||||||
if (!m.enabled || !m.substitute || !m.real) continue;
|
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) {
|
if (identity) {
|
||||||
for (const e of (identity.emails || [])) {
|
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 || [])) {
|
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 || [])) {
|
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 || [])) {
|
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 || [])) {
|
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;
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache
|
// Cache — invalidate when config changes or new substitutions happen
|
||||||
let _revealPairsCache = null;
|
let _revealPairsCache = null;
|
||||||
|
let _revealPairsCacheSize = 0;
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'ss:config-updated') _revealPairsCache = null;
|
if (event.data?.type === 'ss:config-updated') _revealPairsCache = null;
|
||||||
|
if (event.data?.type === 'ss:substitution-performed') _revealPairsCache = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getRevealPairs() {
|
function getRevealPairs() {
|
||||||
|
|||||||
+375
-1
@@ -2,13 +2,20 @@
|
|||||||
* Silent Send - Crypto Module
|
* Silent Send - Crypto Module
|
||||||
*
|
*
|
||||||
* AES-256-GCM encryption with PBKDF2 key derivation.
|
* 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 SALT_LENGTH = 16;
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const ITERATIONS = 100000;
|
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) {
|
async function deriveKey(password, salt) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
@@ -87,6 +94,373 @@ const SilentSendCrypto = {
|
|||||||
throw new Error('Wrong password or corrupted data');
|
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;
|
export default SilentSendCrypto;
|
||||||
|
|||||||
+392
-101
@@ -12,10 +12,16 @@
|
|||||||
* 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV,
|
* 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV,
|
||||||
* self-hosted server, cloud function, etc.).
|
* 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.
|
* Conflict resolution: newest `lastModified` timestamp wins.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import api from './browser-polyfill.js';
|
import api from './browser-polyfill.js';
|
||||||
|
import SilentSendCrypto from './crypto.js';
|
||||||
|
|
||||||
// browser.storage.sync per-item limit (leave generous headroom)
|
// browser.storage.sync per-item limit (leave generous headroom)
|
||||||
const SYNC_CHUNK_SIZE = 5000;
|
const SYNC_CHUNK_SIZE = 5000;
|
||||||
@@ -24,31 +30,303 @@ const SYNC_META_KEY = 'ss_sync_meta';
|
|||||||
|
|
||||||
const SilentSendSync = {
|
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.
|
* Get sync encryption settings from storage.
|
||||||
* Share this code with another browser to import.
|
|
||||||
*/
|
*/
|
||||||
async exportSyncCode() {
|
async _getSyncEncryption() {
|
||||||
const data = await this._getAllData();
|
const result = await api.storage.local.get('ss_sync_encryption');
|
||||||
const json = JSON.stringify(data);
|
return result.ss_sync_encryption || null;
|
||||||
// btoa requires ASCII; use encodeURIComponent to handle Unicode
|
// Shape: { enabled, salt, totpSecret?, authMethod, ttlDays, webauthn? }
|
||||||
return btoa(unescape(encodeURIComponent(json)));
|
},
|
||||||
|
|
||||||
|
async _saveSyncEncryption(config) {
|
||||||
|
await api.storage.local.set({ ss_sync_encryption: config });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import from a sync code string.
|
* Obtain the encryption key — from cache, WebAuthn, or requires password.
|
||||||
* Returns { success, importTime } or { success: false, skipped?, reason, localTime?, importTime? }
|
* Returns { key, salt } or null if auth is required.
|
||||||
*
|
*
|
||||||
* Conflict resolution: newest timestamp wins.
|
* The caller should handle null by prompting the user.
|
||||||
* Pass force=true to override even if local is newer.
|
|
||||||
*/
|
*/
|
||||||
|
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 } = {}) {
|
async importSyncCode(code, { force = false } = {}) {
|
||||||
try {
|
try {
|
||||||
const json = decodeURIComponent(escape(atob(code.trim())));
|
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) {
|
if (!data.version || !data.lastModified) {
|
||||||
return { success: false, reason: 'Invalid sync code — missing version or timestamp.' };
|
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
|
// 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() {
|
async pushToSyncStorage() {
|
||||||
if (!api.storage?.sync) return;
|
if (!api.storage?.sync) return;
|
||||||
try {
|
try {
|
||||||
const data = await this._getAllData();
|
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 = [];
|
const chunks = [];
|
||||||
for (let i = 0; i < json.length; i += SYNC_CHUNK_SIZE) {
|
for (let i = 0; i < json.length; i += SYNC_CHUNK_SIZE) {
|
||||||
chunks.push(json.slice(i, i + SYNC_CHUNK_SIZE));
|
chunks.push(json.slice(i, i + SYNC_CHUNK_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stale chunks
|
|
||||||
const staleKeys = await this._getSyncChunkKeys();
|
const staleKeys = await this._getSyncChunkKeys();
|
||||||
if (staleKeys.length > 0) {
|
if (staleKeys.length > 0) {
|
||||||
await api.storage.sync.remove(staleKeys);
|
await api.storage.sync.remove(staleKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write new chunks + metadata in one shot
|
|
||||||
const toWrite = {
|
const toWrite = {
|
||||||
[SYNC_META_KEY]: { chunks: chunks.length, lastModified: data.lastModified },
|
[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() {
|
async pullFromSyncStorage() {
|
||||||
if (!api.storage?.sync) return null;
|
if (!api.storage?.sync) return null;
|
||||||
try {
|
try {
|
||||||
@@ -123,15 +395,23 @@ const SilentSendSync = {
|
|||||||
const syncMeta = metaResult[SYNC_META_KEY];
|
const syncMeta = metaResult[SYNC_META_KEY];
|
||||||
if (!syncMeta?.chunks) return null;
|
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();
|
const local = await this._getAllData();
|
||||||
if (local.lastModified && local.lastModified >= syncMeta.lastModified) return null;
|
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 chunkKeys = Array.from({ length: syncMeta.chunks }, (_, i) => SYNC_KEY_PREFIX + i);
|
||||||
const chunkResult = await api.storage.sync.get(chunkKeys);
|
const chunkResult = await api.storage.sync.get(chunkKeys);
|
||||||
const json = chunkKeys.map(k => chunkResult[k] || '').join('');
|
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');
|
await this._applyData(data, 'browser-sync');
|
||||||
return { imported: true, time: new Date(data.lastModified).toLocaleString() };
|
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
|
// 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) {
|
async pushToGist(token) {
|
||||||
if (!token) return { success: false, reason: 'No GitHub token provided.' };
|
if (!token) return { success: false, reason: 'No GitHub token provided.' };
|
||||||
try {
|
try {
|
||||||
const data = await this._getAllData();
|
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 stored = await api.storage.local.get('ss_gist_id');
|
||||||
const gistId = stored.ss_gist_id;
|
const gistId = stored.ss_gist_id;
|
||||||
|
|
||||||
let resp;
|
let resp;
|
||||||
if (gistId) {
|
if (gistId) {
|
||||||
// Update existing Gist
|
|
||||||
resp = await fetch(`https://api.github.com/gists/${gistId}`, {
|
resp = await fetch(`https://api.github.com/gists/${gistId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ files: { 'silent-send-sync.json': { content } } }),
|
body: JSON.stringify({ files: { 'silent-send-sync.json': { content } } }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new secret Gist
|
|
||||||
resp = await fetch('https://api.github.com/gists', {
|
resp = await fetch('https://api.github.com/gists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
|
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) {
|
async pullFromGist(token) {
|
||||||
if (!token) return { success: false, reason: 'No GitHub token provided.' };
|
if (!token) return { success: false, reason: 'No GitHub token provided.' };
|
||||||
try {
|
try {
|
||||||
@@ -256,15 +492,28 @@ const SilentSendSync = {
|
|||||||
const file = gist.files?.['silent-send-sync.json'];
|
const file = gist.files?.['silent-send-sync.json'];
|
||||||
if (!file) return { success: false, reason: 'Sync file not found in Gist.' };
|
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 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();
|
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 };
|
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');
|
await this._applyData(data, 'gist');
|
||||||
return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
|
return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -274,23 +523,23 @@ const SilentSendSync = {
|
|||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Custom HTTP endpoint sync
|
// 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 = {} } = {}) {
|
async pushToUrl({ url, method = 'PUT', headers = {} } = {}) {
|
||||||
if (!url) return { success: false, reason: 'No URL provided.' };
|
if (!url) return { success: false, reason: 'No URL provided.' };
|
||||||
try {
|
try {
|
||||||
const data = await this._getAllData();
|
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, {
|
const resp = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json', ...headers },
|
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}` };
|
if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` };
|
||||||
return { success: true };
|
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 = {} } = {}) {
|
async pullFromUrl({ url, headers = {} } = {}) {
|
||||||
if (!url) return { success: false, reason: 'No URL provided.' };
|
if (!url) return { success: false, reason: 'No URL provided.' };
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { headers });
|
const resp = await fetch(url, { headers });
|
||||||
if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` };
|
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();
|
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 };
|
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');
|
await this._applyData(data, 'url');
|
||||||
return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
|
return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
|
||||||
} catch (e) {
|
} 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
|
// 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,
|
_dbPromise: null,
|
||||||
|
|||||||
@@ -100,6 +100,92 @@
|
|||||||
<h2>Sync Between Browsers</h2>
|
<h2>Sync Between Browsers</h2>
|
||||||
<p class="section-desc">Keep your identities, mappings, and settings in sync across browsers.</p>
|
<p class="section-desc">Keep your identities, mappings, and settings in sync across browsers.</p>
|
||||||
|
|
||||||
|
<!-- Sync Encryption -->
|
||||||
|
<div style="margin-bottom:20px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px">
|
||||||
|
<h3 style="font-size:13px;font-weight:600;margin:0 0 8px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span>🔒</span> Sync Encryption
|
||||||
|
</h3>
|
||||||
|
<p class="section-desc" style="margin-bottom:10px">
|
||||||
|
Encrypt your sync data with a password, TOTP, or both. Authentication is only required when new data arrives and your cached key has expired.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="syncEncryptionSetup">
|
||||||
|
<!-- Shown when encryption is NOT set up -->
|
||||||
|
<div id="encryptionNotConfigured">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
|
||||||
|
<div style="flex:1;min-width:160px">
|
||||||
|
<label style="font-size:11px;color:#6b7280;display:block;margin-bottom:2px">Password</label>
|
||||||
|
<input type="password" id="syncEncPassword" placeholder="Encryption password" autocomplete="new-password"
|
||||||
|
style="width:100%;box-sizing:border-box;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:160px">
|
||||||
|
<label style="font-size:11px;color:#6b7280;display:block;margin-bottom:2px">Confirm Password</label>
|
||||||
|
<input type="password" id="syncEncPasswordConfirm" placeholder="Confirm password" autocomplete="new-password"
|
||||||
|
style="width:100%;box-sizing:border-box;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="syncEncTOTP"> Enable TOTP (authenticator app)
|
||||||
|
</label>
|
||||||
|
<label style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer" id="syncEncWebAuthnLabel">
|
||||||
|
<input type="checkbox" id="syncEncWebAuthn"> Biometric/PIN re-auth
|
||||||
|
</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px">
|
||||||
|
<label style="font-size:12px;white-space:nowrap">Re-auth every</label>
|
||||||
|
<select id="syncEncTTL" style="font-size:12px;padding:3px 6px;border:1px solid #d1d5db;border-radius:4px">
|
||||||
|
<option value="0">Each session</option>
|
||||||
|
<option value="30">30 days</option>
|
||||||
|
<option value="90" selected>90 days</option>
|
||||||
|
<option value="180">180 days</option>
|
||||||
|
<option value="365">1 year</option>
|
||||||
|
<option value="-1">Never</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" id="btnSetupEncryption">Enable Encryption</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shown when encryption IS set up -->
|
||||||
|
<div id="encryptionConfigured" style="display:none">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="font-size:12px;color:#10b981;font-weight:500">✓ Encryption active</span>
|
||||||
|
<span id="encryptionInfo" style="font-size:11px;color:#6b7280"></span>
|
||||||
|
<button class="btn btn-sm" id="btnChangeEncPassword">Change Password</button>
|
||||||
|
<button class="btn btn-sm btn-danger" id="btnDisableEncryption">Disable</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth prompt (shown when key cache expired and sync needs decryption) -->
|
||||||
|
<div id="syncAuthPrompt" style="display:none;margin-top:10px;padding:10px;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px">
|
||||||
|
<p style="font-size:12px;font-weight:500;margin:0 0 8px;color:#92400e">Authentication required — new sync data is available</p>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
|
||||||
|
<input type="password" id="syncAuthPassword" placeholder="Password" autocomplete="current-password"
|
||||||
|
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
|
||||||
|
<input type="text" id="syncAuthTOTP" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
|
||||||
|
style="width:80px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;display:none">
|
||||||
|
<button class="btn btn-primary btn-sm" id="btnSyncAuth">Unlock</button>
|
||||||
|
<button class="btn btn-sm" id="btnSyncAuthBiometric" style="display:none">Biometric</button>
|
||||||
|
</div>
|
||||||
|
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOTP setup result -->
|
||||||
|
<div id="totpSetupResult" style="display:none;margin-top:10px;padding:10px;background:#f0fdf4;border:1px solid #86efac;border-radius:6px">
|
||||||
|
<p style="font-size:12px;font-weight:500;margin:0 0 6px;color:#166534">TOTP Secret — save this in your authenticator app:</p>
|
||||||
|
<code id="totpSecretDisplay" style="font-size:13px;font-weight:600;letter-spacing:2px;display:block;margin-bottom:6px;word-break:break-all"></code>
|
||||||
|
<p style="font-size:11px;color:#6b7280;margin:0">Or use the otpauth URI: <code id="totpURIDisplay" style="font-size:10px;word-break:break-all"></code></p>
|
||||||
|
<button class="btn btn-sm" id="btnDismissTOTP" style="margin-top:8px">Done — I've saved it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="syncEncStatus" style="font-size:12px;margin-top:6px;min-height:14px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div>
|
<div>
|
||||||
<label>Browser account sync</label>
|
<label>Browser account sync</label>
|
||||||
|
|||||||
+298
-26
@@ -25,6 +25,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
renderDomains();
|
renderDomains();
|
||||||
renderLog();
|
renderLog();
|
||||||
|
|
||||||
|
// --- Sync Encryption UI ---
|
||||||
|
await initSyncEncryptionUI();
|
||||||
|
|
||||||
// --- Sync section ---
|
// --- Sync section ---
|
||||||
$('#browserSync').addEventListener('change', async (e) => {
|
$('#browserSync').addEventListener('change', async (e) => {
|
||||||
await Storage.saveSettings({ browserSync: e.target.checked });
|
await Storage.saveSettings({ browserSync: e.target.checked });
|
||||||
@@ -38,6 +41,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
$('#btnGenerateSyncCode').addEventListener('click', async () => {
|
$('#btnGenerateSyncCode').addEventListener('click', async () => {
|
||||||
const code = await SilentSendSync.exportSyncCode();
|
const code = await SilentSendSync.exportSyncCode();
|
||||||
|
if (code?.needsAuth) {
|
||||||
|
setSyncStatus('Authentication required to encrypt sync code.', 'warn');
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await SilentSendSync._getAllData();
|
const data = await SilentSendSync._getAllData();
|
||||||
$('#syncCodeText').value = code;
|
$('#syncCodeText').value = code;
|
||||||
$('#syncCodeDisplay').style.display = 'block';
|
$('#syncCodeDisplay').style.display = 'block';
|
||||||
@@ -69,7 +77,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if (!code) return;
|
if (!code) return;
|
||||||
const force = $('#syncForce').checked;
|
const force = $('#syncForce').checked;
|
||||||
const result = await SilentSendSync.importSyncCode(code, { force });
|
const result = await SilentSendSync.importSyncCode(code, { force });
|
||||||
if (result.success) {
|
if (result.needsAuth) {
|
||||||
|
setSyncStatus('Authentication required to decrypt this sync code.', 'warn');
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
} else if (result.success) {
|
||||||
setSyncStatus(`Imported successfully (data from ${result.importTime}).`, 'ok');
|
setSyncStatus(`Imported successfully (data from ${result.importTime}).`, 'ok');
|
||||||
$('#syncImportSection').style.display = 'none';
|
$('#syncImportSection').style.display = 'none';
|
||||||
$('#syncImportText').value = '';
|
$('#syncImportText').value = '';
|
||||||
@@ -136,7 +147,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
|
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
|
||||||
setGistSyncStatus('Pushing…', 'neutral');
|
setGistSyncStatus('Pushing…', 'neutral');
|
||||||
const r = await SilentSendSync.pushToGist(token);
|
const r = await SilentSendSync.pushToGist(token);
|
||||||
if (r.success) {
|
if (r.needsAuth) {
|
||||||
|
setGistSyncStatus('Authentication required to encrypt.', 'warn');
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
} else if (r.success) {
|
||||||
setGistSyncStatus(`Pushed. Gist ID: ${r.gistId.slice(0, 12)}…`, 'ok');
|
setGistSyncStatus(`Pushed. Gist ID: ${r.gistId.slice(0, 12)}…`, 'ok');
|
||||||
} else {
|
} else {
|
||||||
setGistSyncStatus('Push failed: ' + r.reason, 'error');
|
setGistSyncStatus('Push failed: ' + r.reason, 'error');
|
||||||
@@ -148,7 +162,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
|
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
|
||||||
setGistSyncStatus('Pulling…', 'neutral');
|
setGistSyncStatus('Pulling…', 'neutral');
|
||||||
const r = await SilentSendSync.pullFromGist(token);
|
const r = await SilentSendSync.pullFromGist(token);
|
||||||
if (!r.success) {
|
if (r.needsAuth) {
|
||||||
|
setGistSyncStatus('Authentication required to decrypt.', 'warn');
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
} else if (!r.success) {
|
||||||
setGistSyncStatus('Pull failed: ' + r.reason, 'error');
|
setGistSyncStatus('Pull failed: ' + r.reason, 'error');
|
||||||
} else if (r.imported) {
|
} else if (r.imported) {
|
||||||
setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
|
setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
|
||||||
@@ -169,7 +186,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const headers = parseHeadersField($('#customSyncHeaders').value);
|
const headers = parseHeadersField($('#customSyncHeaders').value);
|
||||||
setUrlSyncStatus('Pushing…', 'neutral');
|
setUrlSyncStatus('Pushing…', 'neutral');
|
||||||
const r = await SilentSendSync.pushToUrl({ url, headers });
|
const r = await SilentSendSync.pushToUrl({ url, headers });
|
||||||
if (r.success) {
|
if (r.needsAuth) {
|
||||||
|
setUrlSyncStatus('Authentication required to encrypt.', 'warn');
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
} else if (r.success) {
|
||||||
setUrlSyncStatus('Pushed successfully.', 'ok');
|
setUrlSyncStatus('Pushed successfully.', 'ok');
|
||||||
} else {
|
} else {
|
||||||
setUrlSyncStatus('Push failed: ' + r.reason, 'error');
|
setUrlSyncStatus('Push failed: ' + r.reason, 'error');
|
||||||
@@ -182,7 +202,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const headers = parseHeadersField($('#customSyncHeaders').value);
|
const headers = parseHeadersField($('#customSyncHeaders').value);
|
||||||
setUrlSyncStatus('Pulling…', 'neutral');
|
setUrlSyncStatus('Pulling…', 'neutral');
|
||||||
const r = await SilentSendSync.pullFromUrl({ url, headers });
|
const r = await SilentSendSync.pullFromUrl({ url, headers });
|
||||||
if (!r.success) {
|
if (r.needsAuth) {
|
||||||
|
setUrlSyncStatus('Authentication required to decrypt.', 'warn');
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
} else if (!r.success) {
|
||||||
setUrlSyncStatus('Pull failed: ' + r.reason, 'error');
|
setUrlSyncStatus('Pull failed: ' + r.reason, 'error');
|
||||||
} else if (r.imported) {
|
} else if (r.imported) {
|
||||||
setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
|
setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
|
||||||
@@ -604,17 +627,21 @@ async function pickSyncFolder() {
|
|||||||
async function writeToSyncFile() {
|
async function writeToSyncFile() {
|
||||||
if (!syncDirHandle) return;
|
if (!syncDirHandle) return;
|
||||||
try {
|
try {
|
||||||
// Re-verify permission is still granted (required after browser restart)
|
|
||||||
const perm = await syncDirHandle.requestPermission({ mode: 'readwrite' });
|
const perm = await syncDirHandle.requestPermission({ mode: 'readwrite' });
|
||||||
if (perm !== 'granted') return;
|
if (perm !== 'granted') return;
|
||||||
|
|
||||||
const data = await SilentSendSync._getAllData();
|
const data = await SilentSendSync._getAllData();
|
||||||
|
|
||||||
|
// Encrypt if enabled
|
||||||
|
const encResult = await SilentSendSync._encryptForSync(data);
|
||||||
|
if (encResult.needsAuth) return; // skip silently — will sync after auth
|
||||||
|
const payload = encResult.data || data;
|
||||||
|
|
||||||
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME, { create: true });
|
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME, { create: true });
|
||||||
const writable = await fileHandle.createWritable();
|
const writable = await fileHandle.createWritable();
|
||||||
await writable.write(JSON.stringify(data, null, 2));
|
await writable.write(JSON.stringify(payload, null, 2));
|
||||||
await writable.close();
|
await writable.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Permission denied or folder removed — don't spam errors
|
|
||||||
console.warn('[Silent Send] writeToSyncFile failed:', e.message);
|
console.warn('[Silent Send] writeToSyncFile failed:', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,32 +650,48 @@ async function checkFileSyncUpdate() {
|
|||||||
if (!syncDirHandle) return;
|
if (!syncDirHandle) return;
|
||||||
try {
|
try {
|
||||||
const perm = await syncDirHandle.queryPermission({ mode: 'readwrite' });
|
const perm = await syncDirHandle.queryPermission({ mode: 'readwrite' });
|
||||||
if (perm === 'prompt') {
|
if (perm === 'prompt') return;
|
||||||
// Need a user gesture to re-request — skip silently
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (perm !== 'granted') return;
|
if (perm !== 'granted') return;
|
||||||
|
|
||||||
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME);
|
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME);
|
||||||
const file = await fileHandle.getFile();
|
const file = await fileHandle.getFile();
|
||||||
const data = JSON.parse(await file.text());
|
let data = JSON.parse(await file.text());
|
||||||
|
|
||||||
if (!data.version || !data.lastModified) return;
|
// Check timestamp before requiring auth
|
||||||
|
const remoteMod = data.lastModified;
|
||||||
|
if (!remoteMod) return;
|
||||||
|
|
||||||
const local = await SilentSendSync._getAllData();
|
const local = await SilentSendSync._getAllData();
|
||||||
if (data.lastModified > (local.lastModified || 0)) {
|
if (remoteMod <= (local.lastModified || 0)) return;
|
||||||
await SilentSendSync._applyData(data, 'file');
|
|
||||||
mappings = await Storage.getMappings();
|
// New data exists — decrypt if encrypted
|
||||||
settings = await Storage.getSettings();
|
if (data._ssEncrypted) {
|
||||||
$('#browserSync').checked = settings.browserSync === true;
|
const decResult = await SilentSendSync._decryptFromSync(data);
|
||||||
renderMappings();
|
if (decResult.needsAuth) {
|
||||||
renderDomains();
|
setFileSyncStatus('New sync data available — authentication required.', 'warn');
|
||||||
renderLog();
|
showSyncAuthPrompt();
|
||||||
setFileSyncStatus(
|
return;
|
||||||
'Auto-synced from folder (' + new Date(data.lastModified).toLocaleString() + ').',
|
}
|
||||||
'ok'
|
if (!decResult.data) {
|
||||||
);
|
setFileSyncStatus('Failed to decrypt sync file.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = decResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.version) return;
|
||||||
|
|
||||||
|
await SilentSendSync._applyData(data, 'file');
|
||||||
|
mappings = await Storage.getMappings();
|
||||||
|
settings = await Storage.getSettings();
|
||||||
|
$('#browserSync').checked = settings.browserSync === true;
|
||||||
|
renderMappings();
|
||||||
|
renderDomains();
|
||||||
|
renderLog();
|
||||||
|
setFileSyncStatus(
|
||||||
|
'Auto-synced from folder (' + new Date(data.lastModified).toLocaleString() + ').',
|
||||||
|
'ok'
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name !== 'NotFoundError') {
|
if (e.name !== 'NotFoundError') {
|
||||||
console.warn('[Silent Send] checkFileSyncUpdate failed:', e.message);
|
console.warn('[Silent Send] checkFileSyncUpdate failed:', e.message);
|
||||||
@@ -709,6 +752,235 @@ function setSyncStatus(msg, type) {
|
|||||||
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Sync Encryption UI
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async function initSyncEncryptionUI() {
|
||||||
|
const isEnabled = await SilentSendSync.isEncryptionEnabled();
|
||||||
|
|
||||||
|
if (isEnabled) {
|
||||||
|
showEncryptionConfigured();
|
||||||
|
} else {
|
||||||
|
showEncryptionNotConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide WebAuthn option if not available
|
||||||
|
if (!SilentSendCrypto.isWebAuthnAvailable()) {
|
||||||
|
const label = $('#syncEncWebAuthnLabel');
|
||||||
|
if (label) label.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup encryption button
|
||||||
|
$('#btnSetupEncryption').addEventListener('click', async () => {
|
||||||
|
const password = $('#syncEncPassword').value;
|
||||||
|
const confirm = $('#syncEncPasswordConfirm').value;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
setSyncEncStatus('Enter a password.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setSyncEncStatus('Passwords do not match.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableTOTP = $('#syncEncTOTP').checked;
|
||||||
|
const enableWebAuthn = $('#syncEncWebAuthn').checked;
|
||||||
|
const ttlDays = parseInt($('#syncEncTTL').value, 10);
|
||||||
|
|
||||||
|
setSyncEncStatus('Setting up encryption...', 'neutral');
|
||||||
|
|
||||||
|
const result = await SilentSendSync.setupEncryption({
|
||||||
|
password,
|
||||||
|
enableTOTP,
|
||||||
|
authMethod: enableTOTP ? 'both' : 'password',
|
||||||
|
ttlDays,
|
||||||
|
enableWebAuthn,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
setSyncEncStatus('Setup failed: ' + result.reason, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show TOTP secret if enabled
|
||||||
|
if (result.totpSecret) {
|
||||||
|
$('#totpSecretDisplay').textContent = result.totpSecret;
|
||||||
|
$('#totpURIDisplay').textContent = result.totpURI;
|
||||||
|
$('#totpSetupResult').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear password fields
|
||||||
|
$('#syncEncPassword').value = '';
|
||||||
|
$('#syncEncPasswordConfirm').value = '';
|
||||||
|
|
||||||
|
showEncryptionConfigured();
|
||||||
|
setSyncEncStatus('Encryption enabled. All sync data will be encrypted.', 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss TOTP setup
|
||||||
|
$('#btnDismissTOTP').addEventListener('click', () => {
|
||||||
|
$('#totpSetupResult').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable encryption
|
||||||
|
$('#btnDisableEncryption').addEventListener('click', async () => {
|
||||||
|
if (!window.confirm('Disable sync encryption? Existing encrypted sync data will become unreadable.')) return;
|
||||||
|
await SilentSendSync.disableEncryption();
|
||||||
|
showEncryptionNotConfigured();
|
||||||
|
setSyncEncStatus('Encryption disabled.', 'neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
$('#btnChangeEncPassword').addEventListener('click', async () => {
|
||||||
|
const oldPassword = window.prompt('Enter current password:');
|
||||||
|
if (!oldPassword) return;
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
const authResult = await SilentSendSync.authenticate(oldPassword);
|
||||||
|
if (!authResult.success) {
|
||||||
|
setSyncEncStatus('Wrong current password.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPassword = window.prompt('Enter new password:');
|
||||||
|
if (!newPassword) return;
|
||||||
|
const confirmNew = window.prompt('Confirm new password:');
|
||||||
|
if (newPassword !== confirmNew) {
|
||||||
|
setSyncEncStatus('New passwords do not match.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current config to preserve TOTP and other settings
|
||||||
|
const config = await SilentSendSync._getSyncEncryption();
|
||||||
|
const result = await SilentSendSync.setupEncryption({
|
||||||
|
password: newPassword,
|
||||||
|
enableTOTP: !!config.totpSecret,
|
||||||
|
authMethod: config.authMethod,
|
||||||
|
ttlDays: config.ttlDays,
|
||||||
|
enableWebAuthn: config.webauthn,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setSyncEncStatus('Password changed successfully.', 'ok');
|
||||||
|
} else {
|
||||||
|
setSyncEncStatus('Failed: ' + result.reason, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth prompt — Unlock button
|
||||||
|
$('#btnSyncAuth').addEventListener('click', async () => {
|
||||||
|
const password = $('#syncAuthPassword').value;
|
||||||
|
const totpCode = $('#syncAuthTOTP').value;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
setSyncAuthStatus('Enter your password.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await SilentSendSync.authenticate(password, totpCode || undefined);
|
||||||
|
if (result.success) {
|
||||||
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
|
$('#syncAuthPassword').value = '';
|
||||||
|
$('#syncAuthTOTP').value = '';
|
||||||
|
setSyncEncStatus('Authenticated. Sync data unlocked.', 'ok');
|
||||||
|
} else {
|
||||||
|
setSyncAuthStatus(result.reason, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth prompt — Biometric button
|
||||||
|
$('#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');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSyncAuthStatus('Biometric verification failed.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEncryptionConfigured() {
|
||||||
|
$('#encryptionNotConfigured').style.display = 'none';
|
||||||
|
$('#encryptionConfigured').style.display = 'block';
|
||||||
|
|
||||||
|
const config = await SilentSendSync._getSyncEncryption();
|
||||||
|
if (config) {
|
||||||
|
const parts = [];
|
||||||
|
if (config.authMethod === 'both') parts.push('Password + TOTP');
|
||||||
|
else if (config.authMethod === 'totp') parts.push('TOTP');
|
||||||
|
else parts.push('Password');
|
||||||
|
|
||||||
|
if (config.webauthn) parts.push('Biometric');
|
||||||
|
|
||||||
|
const ttl = config.ttlDays === -1 ? 'never re-auth'
|
||||||
|
: config.ttlDays === 0 ? 'each session'
|
||||||
|
: `every ${config.ttlDays}d`;
|
||||||
|
parts.push(ttl);
|
||||||
|
|
||||||
|
$('#encryptionInfo').textContent = `(${parts.join(' · ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auth is currently needed
|
||||||
|
const needsAuth = await SilentSendSync.needsAuth();
|
||||||
|
if (needsAuth) {
|
||||||
|
showSyncAuthPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEncryptionNotConfigured() {
|
||||||
|
$('#encryptionNotConfigured').style.display = 'block';
|
||||||
|
$('#encryptionConfigured').style.display = 'none';
|
||||||
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
|
$('#totpSetupResult').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSyncAuthPrompt() {
|
||||||
|
const config = await SilentSendSync._getSyncEncryption();
|
||||||
|
$('#syncAuthPrompt').style.display = 'block';
|
||||||
|
|
||||||
|
// Show TOTP field if needed
|
||||||
|
if (config?.totpSecret) {
|
||||||
|
$('#syncAuthTOTP').style.display = '';
|
||||||
|
} else {
|
||||||
|
$('#syncAuthTOTP').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show biometric button if available
|
||||||
|
if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
||||||
|
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
|
||||||
|
$('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none';
|
||||||
|
} else {
|
||||||
|
$('#btnSyncAuthBiometric').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSyncEncStatus(msg, type) {
|
||||||
|
const el = $('#syncEncStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSyncAuthStatus(msg, type) {
|
||||||
|
const el = $('#syncAuthStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
|
|||||||
Reference in New Issue
Block a user