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
+86
View File
@@ -100,6 +100,92 @@
<h2>Sync Between Browsers</h2>
<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>&#128274;</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">&#10003; 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>
<label>Browser account sync</label>
+298 -26
View File
@@ -25,6 +25,9 @@ document.addEventListener('DOMContentLoaded', async () => {
renderDomains();
renderLog();
// --- Sync Encryption UI ---
await initSyncEncryptionUI();
// --- Sync section ---
$('#browserSync').addEventListener('change', async (e) => {
await Storage.saveSettings({ browserSync: e.target.checked });
@@ -38,6 +41,11 @@ document.addEventListener('DOMContentLoaded', async () => {
$('#btnGenerateSyncCode').addEventListener('click', async () => {
const code = await SilentSendSync.exportSyncCode();
if (code?.needsAuth) {
setSyncStatus('Authentication required to encrypt sync code.', 'warn');
showSyncAuthPrompt();
return;
}
const data = await SilentSendSync._getAllData();
$('#syncCodeText').value = code;
$('#syncCodeDisplay').style.display = 'block';
@@ -69,7 +77,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!code) return;
const force = $('#syncForce').checked;
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');
$('#syncImportSection').style.display = 'none';
$('#syncImportText').value = '';
@@ -136,7 +147,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
setGistSyncStatus('Pushing…', 'neutral');
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');
} else {
setGistSyncStatus('Push failed: ' + r.reason, 'error');
@@ -148,7 +162,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
setGistSyncStatus('Pulling…', 'neutral');
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');
} else if (r.imported) {
setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
@@ -169,7 +186,10 @@ document.addEventListener('DOMContentLoaded', async () => {
const headers = parseHeadersField($('#customSyncHeaders').value);
setUrlSyncStatus('Pushing…', 'neutral');
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');
} else {
setUrlSyncStatus('Push failed: ' + r.reason, 'error');
@@ -182,7 +202,10 @@ document.addEventListener('DOMContentLoaded', async () => {
const headers = parseHeadersField($('#customSyncHeaders').value);
setUrlSyncStatus('Pulling…', 'neutral');
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');
} else if (r.imported) {
setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
@@ -604,17 +627,21 @@ async function pickSyncFolder() {
async function writeToSyncFile() {
if (!syncDirHandle) return;
try {
// Re-verify permission is still granted (required after browser restart)
const perm = await syncDirHandle.requestPermission({ mode: 'readwrite' });
if (perm !== 'granted') return;
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 writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.write(JSON.stringify(payload, null, 2));
await writable.close();
} catch (e) {
// Permission denied or folder removed — don't spam errors
console.warn('[Silent Send] writeToSyncFile failed:', e.message);
}
}
@@ -623,32 +650,48 @@ async function checkFileSyncUpdate() {
if (!syncDirHandle) return;
try {
const perm = await syncDirHandle.queryPermission({ mode: 'readwrite' });
if (perm === 'prompt') {
// Need a user gesture to re-request — skip silently
return;
}
if (perm === 'prompt') return;
if (perm !== 'granted') return;
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME);
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();
if (data.lastModified > (local.lastModified || 0)) {
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'
);
if (remoteMod <= (local.lastModified || 0)) return;
// New data exists — decrypt if encrypted
if (data._ssEncrypted) {
const decResult = await SilentSendSync._decryptFromSync(data);
if (decResult.needsAuth) {
setFileSyncStatus('New sync data available — authentication required.', 'warn');
showSyncAuthPrompt();
return;
}
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) {
if (e.name !== 'NotFoundError') {
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';
}
// ----------------------------------------------------------------
// 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) {
const div = document.createElement('div');
div.textContent = str;