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
+43 -8
View File
@@ -413,7 +413,7 @@
</div>
${items}
${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');
@@ -508,10 +508,26 @@
return { text: result, redactions };
}
// ============================================================
// Track outbound substitutions — only these get revealed
//
// Maps substitute value (lowercase) → real value so reveal
// only replaces values that were actually sent to the AI,
// preventing false positives like "user" in AI prose.
// ============================================================
const sessionSubstitutions = new Map();
// ============================================================
// Notify content script of substitutions (for badge + logging)
// ============================================================
function notifySubstitutions(replacements) {
// Record what was actually substituted so reveal knows
for (const r of replacements) {
if (r.replaced && r.original) {
sessionSubstitutions.set(r.replaced.toLowerCase(), r.original);
}
}
window.postMessage({
type: 'ss:substitution-performed',
count: replacements.length,
@@ -736,29 +752,46 @@
}
// Build pairs: substitute → real
// Only includes substitutes that were actually sent outbound in this
// session, preventing false positives (e.g. "user" in AI prose).
function buildRevealPairs() {
const pairs = [];
// Helper: only add if this substitute was actually sent
function addIfUsed(from, to, caseSensitive) {
if (!from || !to) return;
if (sessionSubstitutions.has(from.toLowerCase())) {
pairs.push({ from, to, caseSensitive });
}
}
for (const m of mappings) {
if (!m.enabled || !m.substitute || !m.real) continue;
pairs.push({ from: m.substitute, to: m.real, caseSensitive: m.caseSensitive });
addIfUsed(m.substitute, m.real, m.caseSensitive);
}
if (identity) {
for (const e of (identity.emails || [])) {
if (e.substitute && e.real) pairs.push({ from: e.substitute, to: e.real });
addIfUsed(e.substitute, e.real);
}
for (const n of (identity.names || [])) {
if (n.substitute && n.real) pairs.push({ from: n.substitute, to: n.real });
addIfUsed(n.substitute, n.real);
}
for (const u of (identity.usernames || [])) {
if (u.substitute && u.real) pairs.push({ from: u.substitute, to: u.real });
addIfUsed(u.substitute, u.real);
}
for (const h of (identity.hostnames || [])) {
if (h.substitute && h.real) pairs.push({ from: h.substitute, to: h.real });
addIfUsed(h.substitute, h.real);
}
for (const p of (identity.phones || [])) {
if (p.substitute && p.real) pairs.push({ from: p.substitute, to: p.real });
addIfUsed(p.substitute, p.real);
}
}
// Also add auto-detect and secret scanner substitutions from this session
for (const [replaced, original] of sessionSubstitutions) {
if (!pairs.some(p => p.from.toLowerCase() === replaced)) {
pairs.push({ from: replaced, to: original });
}
}
@@ -766,10 +799,12 @@
return pairs;
}
// Cache
// Cache — invalidate when config changes or new substitutions happen
let _revealPairsCache = null;
let _revealPairsCacheSize = 0;
window.addEventListener('message', (event) => {
if (event.data?.type === 'ss:config-updated') _revealPairsCache = null;
if (event.data?.type === 'ss:substitution-performed') _revealPairsCache = null;
});
function getRevealPairs() {