feat: at-rest encryption for all sensitive data + vault unlock flow

All sensitive data (identity, mappings, activity log) is now AES-256
encrypted in browser.storage.local when sync encryption is enabled.
TOTP secret is also encrypted at rest using the derived key.

Vault unlock flow:
- On browser restart, extension detects locked state (encrypted data,
  no cached CryptoKey) and shows LOCK badge in red
- Popup shows a full-screen unlock prompt with password field,
  optional TOTP, and biometric button
- After unlock, background decrypts and broadcasts data to all tabs
- Content scripts start with empty config when locked; receive
  decrypted config via vault:unlocked message after unlock
- Injector skips encrypted blobs in storage change events

Storage module changes:
- _readSecure / _writeSecure transparently encrypt/decrypt
- encryptExistingData() migrates plaintext → encrypted on setup
- decryptAllData() restores plaintext when encryption is disabled
- isLocked() checks for encrypted data + missing key

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 19:03:54 +00:00
parent 39e609a14e
commit a22ba549a2
7 changed files with 418 additions and 41 deletions
+23 -3
View File
@@ -35,6 +35,27 @@
<button class="tab" data-tab="test">Test</button>
</nav>
<!-- Locked State Overlay -->
<div id="lockedOverlay" style="display:none">
<div style="padding:24px 16px;text-align:center">
<div style="font-size:32px;margin-bottom:8px">&#128274;</div>
<h2 style="font-size:15px;margin:0 0 6px;color:#1f2937">Silent Send is Locked</h2>
<p style="font-size:12px;color:#6b7280;margin:0 0 16px">
Your data is encrypted. Enter your password to unlock.
<br>Substitutions are <strong>paused</strong> until unlocked.
</p>
<div style="display:flex;flex-direction:column;gap:8px;max-width:260px;margin:0 auto">
<input type="password" id="unlockPassword" placeholder="Encryption password" autocomplete="current-password"
style="width:100%;box-sizing:border-box;font-size:13px;padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;text-align:center">
<input type="text" id="unlockTOTP" placeholder="TOTP code (if enabled)" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
style="width:100%;box-sizing:border-box;font-size:13px;padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;text-align:center;display:none">
<button class="btn btn-primary" id="btnUnlock" style="width:100%;padding:8px;font-size:13px">Unlock</button>
<button class="btn" id="btnUnlockBiometric" style="width:100%;padding:8px;font-size:13px;display:none">Unlock with Biometric</button>
<div id="unlockStatus" style="font-size:11px;min-height:16px;color:#dc2626"></div>
</div>
</div>
</div>
<!-- First-Run Setup Banner -->
<div class="first-run-banner" id="firstRunBanner" style="display:none">
<div class="first-run-icon">!</div>
@@ -175,9 +196,8 @@
<footer class="footer">
<div class="privacy-note">
Your data never leaves your browser. No servers, no tracking, no analytics.
Identity data is stored unencrypted in local browser storage — anyone with
access to your computer could read it. This is the same as browser cookies
and localStorage, not as secure as saved passwords (which are OS-encrypted).
<span id="privacyEncNote">When sync encryption is enabled, identity data is
AES-256 encrypted at rest — unreadable without your password.</span>
</div>
<div class="privacy-note" style="color:#b45309;background:#fef3c7;padding:6px 8px;border-radius:4px;margin-bottom:6px">
Silent Send is a convenience tool, not a security guarantee. It can miss
+107 -1
View File
@@ -3,6 +3,8 @@ import SmartPatterns from '../lib/smart-patterns.js';
import SecretScanner from '../lib/secret-scanner.js';
import AutoDetect from '../lib/auto-detect.js';
import Storage from '../lib/storage.js';
import SilentSendSync from '../lib/sync.js';
import SilentSendCrypto from '../lib/crypto.js';
import api from '../lib/browser-polyfill.js';
// --- State ---
@@ -18,6 +20,20 @@ const $$ = (sel) => document.querySelectorAll(sel);
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
// Check if locked BEFORE trying to read sensitive data
const locked = await Storage.isLocked();
if (locked) {
showLockedUI();
return;
}
await initUnlockedUI();
});
async function initUnlockedUI() {
// Hide locked overlay, show normal UI
$('#lockedOverlay').style.display = 'none';
mappings = await Storage.getMappings();
profiles = await Storage.getProfiles();
identity = await Storage.getIdentity();
@@ -191,7 +207,97 @@ document.addEventListener('DOMContentLoaded', async () => {
e.preventDefault();
api.runtime.openOptionsPage();
});
});
// Update privacy note based on encryption state
const encEnabled = await Storage._isAtRestEncryptionEnabled();
const encNote = $('#privacyEncNote');
if (encNote) {
encNote.style.display = encEnabled ? '' : 'none';
}
}
// --- Locked UI ---
async function showLockedUI() {
// Hide all normal UI elements
const lockedOverlay = $('#lockedOverlay');
lockedOverlay.style.display = 'block';
// Check if TOTP is configured
const encConfig = await SilentSendSync._getSyncEncryption();
if (encConfig?.totpSecret) {
$('#unlockTOTP').style.display = '';
}
// Check if WebAuthn is available
if (encConfig?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
if (hasCred) {
$('#btnUnlockBiometric').style.display = '';
}
}
// Unlock with password (+ optional TOTP)
$('#btnUnlock').addEventListener('click', async () => {
const password = $('#unlockPassword').value;
const totpCode = $('#unlockTOTP').value;
if (!password) {
$('#unlockStatus').textContent = 'Enter your password.';
return;
}
$('#unlockStatus').textContent = 'Unlocking...';
$('#unlockStatus').style.color = '#6b7280';
const result = await SilentSendSync.authenticate(password, totpCode || undefined);
if (result.success) {
$('#unlockStatus').textContent = '';
// Notify background to clear LOCK badge
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
// Transition to normal UI
await initUnlockedUI();
} else {
$('#unlockStatus').textContent = result.reason;
$('#unlockStatus').style.color = '#dc2626';
}
});
// Enter key triggers unlock
$('#unlockPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#btnUnlock').click();
});
$('#unlockTOTP').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#btnUnlock').click();
});
// Unlock with biometric
$('#btnUnlockBiometric').addEventListener('click', async () => {
$('#unlockStatus').textContent = 'Waiting for biometric...';
$('#unlockStatus').style.color = '#6b7280';
const verified = await SilentSendCrypto.webAuthnAuthenticate();
if (verified) {
const ttlDays = encConfig?.ttlDays ?? 90;
await SilentSendCrypto.markVerified(ttlDays);
// The key should already be in IndexedDB from prior session
const cached = await SilentSendCrypto.getCachedKey();
if (cached) {
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
await initUnlockedUI();
} else {
$('#unlockStatus').textContent = 'Key not found. Enter password.';
$('#unlockStatus').style.color = '#dc2626';
}
} else {
$('#unlockStatus').textContent = 'Biometric failed.';
$('#unlockStatus').style.color = '#dc2626';
}
});
// Focus password field
setTimeout(() => $('#unlockPassword').focus(), 100);
}
// --- Profiles ---
function renderProfileSelector() {