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
+46
View File
@@ -153,6 +153,44 @@ const messageHandlers = {
api.action.setBadgeBackgroundColor({ color: '#6b7280' }); api.action.setBadgeBackgroundColor({ color: '#6b7280' });
}, },
async 'get:locked-state'(_message, _sender, sendResponse) {
const locked = await Storage.isLocked();
sendResponse({ locked });
},
async 'vault:unlocked'() {
// User unlocked the vault — clear the LOCK badge and refresh icon
api.action.setBadgeText({ text: '' });
const settings = await Storage.getSettings();
await updateIcon(settings);
// Now that we're unlocked, try syncing
if (settings.browserSync) {
await SilentSendSync.pullFromSyncStorage();
}
// Read decrypted data via Storage module and send to all content scripts
const mappings = await Storage.getMappings();
const identity = await Storage.getIdentity();
const allPatterns = [...BUILTIN_URL_PATTERNS];
const customDomains = settings.customDomains || [];
for (const domain of customDomains) {
allPatterns.push(domain + '/*');
}
for (const urlPattern of allPatterns) {
const tabs = await api.tabs.query({ url: urlPattern }).catch(() => []);
for (const tab of tabs) {
api.tabs.sendMessage(tab.id, {
type: 'vault:unlocked',
mappings,
identity,
settings,
}).catch(() => {});
}
}
},
async 'update:settings'(message) { async 'update:settings'(message) {
await Storage.saveSettings(message.settings); await Storage.saveSettings(message.settings);
@@ -372,6 +410,14 @@ api.runtime.onInstalled.addListener(async () => {
const settings = await Storage.getSettings(); const settings = await Storage.getSettings();
await updateIcon(settings); await updateIcon(settings);
// Check if extension is locked (encrypted data, no cached key)
const locked = await Storage.isLocked();
if (locked) {
api.action.setBadgeText({ text: 'LOCK' });
api.action.setBadgeBackgroundColor({ color: '#dc2626' });
return; // don't try to sync while locked
}
// Restore the SYN badge if the user hasn't opened Options since the last sync // Restore the SYN badge if the user hasn't opened Options since the last sync
const stored = await api.storage.local.get('ss_sync_notification'); const stored = await api.storage.local.get('ss_sync_notification');
if (stored.ss_sync_notification) { if (stored.ss_sync_notification) {
+34 -7
View File
@@ -58,11 +58,16 @@
// Load mappings and settings, then inject into page // Load mappings and settings, then inject into page
async function init() { async function init() {
const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']); const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']);
const mappings = result.ss_mappings || [];
const settings = result.ss_settings || { enabled: true }; const settings = result.ss_settings || { enabled: true };
// Check if data is encrypted (locked) — pass empty config
// The background will send decrypted data via vault:unlocked when ready
const isLocked = result.ss_mappings?._ssLocalEncrypted ||
result.ss_identity?._ssLocalEncrypted;
const mappings = isLocked ? [] : (result.ss_mappings || []);
const identityData = isLocked ? {} : (result.ss_identity || {});
// Merge active profiles into a flat identity object for the content script // Merge active profiles into a flat identity object for the content script
const identityData = result.ss_identity || {};
const identity = mergeProfiles(identityData); const identity = mergeProfiles(identityData);
// Inject the main interception script into the page's world // Inject the main interception script into the page's world
@@ -107,24 +112,46 @@
}); });
// Forward storage changes to the page script (merge profiles before sending) // Forward storage changes to the page script (merge profiles before sending)
// Skip encrypted blobs — background will send decrypted data via vault:unlocked
api.storage.onChanged.addListener((changes) => { api.storage.onChanged.addListener((changes) => {
if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) { if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) {
const msg = { type: 'ss:config-updated' }; const msg = { type: 'ss:config-updated' };
if (changes.ss_mappings) msg.mappings = changes.ss_mappings.newValue;
if (changes.ss_identity) msg.identity = mergeProfiles(changes.ss_identity.newValue); if (changes.ss_mappings) {
const val = changes.ss_mappings.newValue;
if (!val?._ssLocalEncrypted) msg.mappings = val;
}
if (changes.ss_identity) {
const val = changes.ss_identity.newValue;
if (!val?._ssLocalEncrypted) msg.identity = mergeProfiles(val);
}
if (changes.ss_settings) msg.settings = changes.ss_settings.newValue; if (changes.ss_settings) msg.settings = changes.ss_settings.newValue;
window.postMessage(msg, '*');
// Only post if we have something meaningful to send
if (msg.mappings || msg.identity || msg.settings) {
window.postMessage(msg, '*');
}
} }
}); });
// Listen for settings updates from popup via runtime messages // Listen for settings updates and vault unlock from background
api.runtime.onMessage.addListener((message) => { api.runtime.onMessage.addListener(async (message) => {
if (message.type === 'settings:updated') { if (message.type === 'settings:updated') {
window.postMessage({ window.postMessage({
type: 'ss:config-updated', type: 'ss:config-updated',
settings: message.settings, settings: message.settings,
}, '*'); }, '*');
} }
// Vault unlocked — background sends pre-decrypted data
if (message.type === 'vault:unlocked') {
window.postMessage({
type: 'ss:config-updated',
mappings: message.mappings || [],
identity: message.identity || {},
settings: message.settings || {},
}, '*');
}
}); });
// Storage bridge — lets page world script read/write storage // Storage bridge — lets page world script read/write storage
+136 -10
View File
@@ -3,9 +3,18 @@
* *
* Wraps browser/api.storage.local with typed helpers for mappings, * Wraps browser/api.storage.local with typed helpers for mappings,
* activity log, and settings. * activity log, and settings.
*
* When at-rest encryption is enabled, sensitive data (identity, mappings,
* activity log) is AES-encrypted before writing to storage.local and
* decrypted on read. The encryption key comes from the key cache in
* IndexedDB (derived from the user's password on first setup).
*
* Non-sensitive data (settings, sync metadata) remains plaintext so the
* extension can function in a "locked" state (showing the unlock prompt).
*/ */
import api from './browser-polyfill.js'; import api from './browser-polyfill.js';
import SilentSendCrypto from './crypto.js';
const KEYS = { const KEYS = {
MAPPINGS: 'ss_mappings', MAPPINGS: 'ss_mappings',
@@ -14,6 +23,9 @@ const KEYS = {
SETTINGS: 'ss_settings', SETTINGS: 'ss_settings',
}; };
// Keys that contain sensitive PPI and should be encrypted at rest
const ENCRYPTED_KEYS = new Set([KEYS.MAPPINGS, KEYS.IDENTITY, KEYS.LOG]);
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
enabled: true, enabled: true,
showHighlights: false, showHighlights: false,
@@ -29,15 +41,128 @@ const DEFAULT_SETTINGS = {
}; };
const Storage = { const Storage = {
// ----------------------------------------------------------------
// At-rest encryption helpers
// ----------------------------------------------------------------
/**
* Check if at-rest encryption is enabled.
* At-rest encryption piggybacks on sync encryption — if the user
* has set up sync encryption, local storage is also encrypted.
*/
async _isAtRestEncryptionEnabled() {
const result = await api.storage.local.get('ss_sync_encryption');
return !!result.ss_sync_encryption?.enabled;
},
/**
* Read a potentially-encrypted value from storage.
* Returns the decrypted value, or null if locked.
*/
async _readSecure(key) {
const result = await api.storage.local.get(key);
const value = result[key];
// Not encrypted — return as-is
if (!value || !value._ssLocalEncrypted) return value || null;
// Encrypted — need the cached key
const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return null; // locked
try {
return await SilentSendCrypto.decryptWithKey(value.data, cached.key);
} catch {
return null; // corrupted or wrong key
}
},
/**
* Write a value to storage, encrypting if at-rest encryption is enabled.
*/
async _writeSecure(key, value, extras = {}) {
const isEncEnabled = await this._isAtRestEncryptionEnabled();
if (isEncEnabled && ENCRYPTED_KEYS.has(key)) {
const cached = await SilentSendCrypto.getCachedKey();
if (cached) {
const encrypted = await SilentSendCrypto.encryptWithKey(value, cached.key);
await api.storage.local.set({
[key]: { _ssLocalEncrypted: true, data: encrypted },
...extras,
});
return;
}
// No key available — fall through to plaintext (shouldn't happen
// if the UI flow is correct, but better than losing data)
}
await api.storage.local.set({ [key]: value, ...extras });
},
/**
* Check if the extension is in a locked state (encrypted data, no key).
*/
async isLocked() {
const isEncEnabled = await this._isAtRestEncryptionEnabled();
if (!isEncEnabled) return false;
const cached = await SilentSendCrypto.getCachedKey();
if (cached) return false;
return true;
},
/**
* Encrypt all existing plaintext sensitive data after encryption is
* first enabled. Called once when the user sets up sync encryption.
*/
async encryptExistingData() {
const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return;
for (const key of ENCRYPTED_KEYS) {
const result = await api.storage.local.get(key);
const value = result[key];
// Skip if already encrypted or empty
if (!value || value._ssLocalEncrypted) continue;
const encrypted = await SilentSendCrypto.encryptWithKey(value, cached.key);
await api.storage.local.set({
[key]: { _ssLocalEncrypted: true, data: encrypted },
});
}
},
/**
* Decrypt all encrypted data back to plaintext. Called when the user
* disables sync encryption.
*/
async decryptAllData() {
const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return;
for (const key of ENCRYPTED_KEYS) {
const result = await api.storage.local.get(key);
const value = result[key];
if (!value?._ssLocalEncrypted) continue;
try {
const decrypted = await SilentSendCrypto.decryptWithKey(value.data, cached.key);
await api.storage.local.set({ [key]: decrypted });
} catch { /* leave encrypted if decryption fails */ }
}
},
// --- Mappings --- // --- Mappings ---
async getMappings() { async getMappings() {
const result = await api.storage.local.get(KEYS.MAPPINGS); const data = await this._readSecure(KEYS.MAPPINGS);
return result[KEYS.MAPPINGS] || []; return data || [];
}, },
async saveMappings(mappings) { async saveMappings(mappings) {
await api.storage.local.set({ [KEYS.MAPPINGS]: mappings, ss_lastModified: Date.now() }); await this._writeSecure(KEYS.MAPPINGS, mappings, { ss_lastModified: Date.now() });
}, },
async addMapping(mapping) { async addMapping(mapping) {
@@ -90,14 +215,13 @@ const Storage = {
}, },
async getProfiles() { async getProfiles() {
const result = await api.storage.local.get(KEYS.IDENTITY); const data = await this._readSecure(KEYS.IDENTITY);
const data = result[KEYS.IDENTITY];
if (data?.profiles) return data.profiles; if (data?.profiles) return data.profiles;
return []; return [];
}, },
async saveProfiles(profiles) { async saveProfiles(profiles) {
await api.storage.local.set({ [KEYS.IDENTITY]: { profiles }, ss_lastModified: Date.now() }); await this._writeSecure(KEYS.IDENTITY, { profiles }, { ss_lastModified: Date.now() });
}, },
async addProfile(name) { async addProfile(name) {
@@ -174,8 +298,8 @@ const Storage = {
// --- Activity Log --- // --- Activity Log ---
async getLog() { async getLog() {
const result = await api.storage.local.get(KEYS.LOG); const data = await this._readSecure(KEYS.LOG);
return result[KEYS.LOG] || []; return data || [];
}, },
async addLogEntry(entry) { async addLogEntry(entry) {
@@ -193,14 +317,16 @@ const Storage = {
log.length = settings.maxLogEntries; log.length = settings.maxLogEntries;
} }
await api.storage.local.set({ [KEYS.LOG]: log }); await this._writeSecure(KEYS.LOG, log);
}, },
async clearLog() { async clearLog() {
await api.storage.local.set({ [KEYS.LOG]: [] }); await this._writeSecure(KEYS.LOG, []);
}, },
// --- Settings --- // --- Settings ---
// Settings are NOT encrypted — they contain no PPI and are needed
// for the extension to show basic UI (locked state, badge, etc.)
async getSettings() { async getSettings() {
const result = await api.storage.local.get(KEYS.SETTINGS); const result = await api.storage.local.get(KEYS.SETTINGS);
+69 -18
View File
@@ -43,7 +43,36 @@ const SilentSendSync = {
}, },
async _saveSyncEncryption(config) { async _saveSyncEncryption(config) {
await api.storage.local.set({ ss_sync_encryption: config }); // Encrypt the TOTP secret at rest if we have a cached key
const toStore = { ...config };
if (toStore.totpSecret) {
const cached = await SilentSendCrypto.getCachedKey();
if (cached) {
toStore._totpEncrypted = await SilentSendCrypto.encryptWithKey(
{ secret: toStore.totpSecret }, cached.key
);
delete toStore.totpSecret; // don't store plaintext
}
}
await api.storage.local.set({ ss_sync_encryption: toStore });
},
/**
* Get the decrypted TOTP secret (if configured and key is available).
*/
async _getTOTPSecret(config) {
if (config.totpSecret) return config.totpSecret; // already plaintext (legacy)
if (!config._totpEncrypted) return null;
const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return null;
try {
const decrypted = await SilentSendCrypto.decryptWithKey(config._totpEncrypted, cached.key);
return decrypted.secret;
} catch {
return null;
}
}, },
/** /**
@@ -150,17 +179,10 @@ const SilentSendSync = {
const config = await this._getSyncEncryption(); const config = await this._getSyncEncryption();
if (!config?.enabled) return { success: true }; if (!config?.enabled) return { success: true };
// Validate TOTP if configured // Derive key from password first (needed to decrypt TOTP secret)
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); const { key, salt } = await SilentSendCrypto.deriveAndReturnKey(password, config.salt);
// Verify the password is correct by trying to decrypt the verification blob // Verify the password is correct
if (config.verificationBlob) { if (config.verificationBlob) {
try { try {
await SilentSendCrypto.decryptWithKey(config.verificationBlob, key); await SilentSendCrypto.decryptWithKey(config.verificationBlob, key);
@@ -169,6 +191,22 @@ const SilentSendSync = {
} }
} }
// Validate TOTP if configured (after deriving key, since TOTP secret
// may be encrypted at rest and needs the key to decrypt)
const hasTOTP = config.totpSecret || config._totpEncrypted;
if (hasTOTP) {
if (!totpCode) return { success: false, reason: 'TOTP code required.' };
// Temporarily cache key so _getTOTPSecret can decrypt
await SilentSendCrypto.cacheKey(key, salt, config.ttlDays ?? 90);
const secret = await this._getTOTPSecret(config);
if (!secret) return { success: false, reason: 'Could not decrypt TOTP secret.' };
const valid = await SilentSendCrypto.validateTOTP(secret, totpCode);
if (!valid) {
await SilentSendCrypto.clearCachedKey(); // don't leave key cached on TOTP failure
return { success: false, reason: 'Invalid TOTP code.' };
}
}
// Cache the key persistently (never auto-deletes) // Cache the key persistently (never auto-deletes)
const ttlDays = config.ttlDays ?? 90; const ttlDays = config.ttlDays ?? 90;
await SilentSendCrypto.cacheKey(key, salt, ttlDays); await SilentSendCrypto.cacheKey(key, salt, ttlDays);
@@ -200,14 +238,18 @@ const SilentSendSync = {
async reverifyWithTOTP(totpCode) { async reverifyWithTOTP(totpCode) {
const config = await this._getSyncEncryption(); const config = await this._getSyncEncryption();
if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' }; if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' };
if (!config.totpSecret) return { success: false, reason: 'TOTP not configured.' };
const hasTOTP = config.totpSecret || config._totpEncrypted;
if (!hasTOTP) return { success: false, reason: 'TOTP not configured.' };
// Must have a cached key — TOTP can't derive one // Must have a cached key — TOTP can't derive one
const cached = await SilentSendCrypto.getCachedKey(); const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return { success: false, reason: 'No cached key. Password required for first setup.' }; if (!cached) return { success: false, reason: 'No cached key. Password required for first setup.' };
// Validate the TOTP code // Decrypt the TOTP secret and validate
const valid = await SilentSendCrypto.validateTOTP(config.totpSecret, totpCode); const secret = await this._getTOTPSecret(config);
if (!secret) return { success: false, reason: 'Could not decrypt TOTP secret.' };
const valid = await SilentSendCrypto.validateTOTP(secret, totpCode);
if (!valid) return { success: false, reason: 'Invalid TOTP code.' }; if (!valid) return { success: false, reason: 'Invalid TOTP code.' };
// Reset the TTL timer // Reset the TTL timer
@@ -287,19 +329,20 @@ const SilentSendSync = {
webauthn: enableWebAuthn, webauthn: enableWebAuthn,
}; };
// Cache the key immediately (needed before _saveSyncEncryption
// can encrypt the TOTP secret)
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
let totpSecret, totpURI; let totpSecret, totpURI;
if (enableTOTP) { if (enableTOTP) {
totpSecret = SilentSendCrypto.generateTOTPSecret(); totpSecret = SilentSendCrypto.generateTOTPSecret();
totpURI = SilentSendCrypto.totpURI(totpSecret); totpURI = SilentSendCrypto.totpURI(totpSecret);
config.totpSecret = totpSecret; config.totpSecret = totpSecret; // _saveSyncEncryption will encrypt this
if (authMethod === 'password') config.authMethod = 'both'; if (authMethod === 'password') config.authMethod = 'both';
} }
await this._saveSyncEncryption(config); await this._saveSyncEncryption(config);
// Cache the key immediately
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
// Set up WebAuthn if requested // Set up WebAuthn if requested
if (enableWebAuthn && SilentSendCrypto.isWebAuthnAvailable()) { if (enableWebAuthn && SilentSendCrypto.isWebAuthnAvailable()) {
try { try {
@@ -312,6 +355,10 @@ const SilentSendSync = {
} }
} }
// Encrypt any existing plaintext sensitive data in storage
const StorageModule = (await import('./storage.js')).default;
await StorageModule.encryptExistingData();
return { success: true, totpSecret, totpURI }; return { success: true, totpSecret, totpURI };
}, },
@@ -319,6 +366,10 @@ const SilentSendSync = {
* Disable sync encryption entirely. * Disable sync encryption entirely.
*/ */
async disableEncryption() { async disableEncryption() {
// Decrypt all data back to plaintext before removing encryption config
const StorageModule = (await import('./storage.js')).default;
await StorageModule.decryptAllData();
await api.storage.local.remove('ss_sync_encryption'); await api.storage.local.remove('ss_sync_encryption');
await SilentSendCrypto.clearCachedKey(); await SilentSendCrypto.clearCachedKey();
await SilentSendCrypto.clearWebAuthnCredential(); await SilentSendCrypto.clearWebAuthnCredential();
@@ -379,7 +430,7 @@ const SilentSendSync = {
authMethod: config.authMethod, authMethod: config.authMethod,
ttlDays: config.ttlDays, ttlDays: config.ttlDays,
webauthn: config.webauthn, webauthn: config.webauthn,
totpSecret: config.totpSecret || null, totpSecret: await this._getTOTPSecret(config) || null,
}, },
}; };
+3 -2
View File
@@ -1002,7 +1002,8 @@ async function showSyncAuthPrompt(mode = 'decrypt') {
} }
// First-device: show TOTP alongside password if configured // First-device: show TOTP alongside password if configured
if (!isReverify && config?.totpSecret) { const hasTOTP = config?.totpSecret || config?._totpEncrypted;
if (!isReverify && hasTOTP) {
$('#syncAuthTOTPForPassword').style.display = ''; $('#syncAuthTOTPForPassword').style.display = '';
} else { } else {
$('#syncAuthTOTPForPassword').style.display = 'none'; $('#syncAuthTOTPForPassword').style.display = 'none';
@@ -1023,7 +1024,7 @@ async function showSyncAuthPrompt(mode = 'decrypt') {
// TOTP re-verify option // TOTP re-verify option
const totpGroup = $('#totpReverifyGroup'); const totpGroup = $('#totpReverifyGroup');
if (config?.totpSecret) { if (hasTOTP) {
totpGroup.style.display = 'flex'; totpGroup.style.display = 'flex';
} else { } else {
totpGroup.style.display = 'none'; totpGroup.style.display = 'none';
+23 -3
View File
@@ -35,6 +35,27 @@
<button class="tab" data-tab="test">Test</button> <button class="tab" data-tab="test">Test</button>
</nav> </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 --> <!-- First-Run Setup Banner -->
<div class="first-run-banner" id="firstRunBanner" style="display:none"> <div class="first-run-banner" id="firstRunBanner" style="display:none">
<div class="first-run-icon">!</div> <div class="first-run-icon">!</div>
@@ -175,9 +196,8 @@
<footer class="footer"> <footer class="footer">
<div class="privacy-note"> <div class="privacy-note">
Your data never leaves your browser. No servers, no tracking, no analytics. Your data never leaves your browser. No servers, no tracking, no analytics.
Identity data is stored unencrypted in local browser storage — anyone with <span id="privacyEncNote">When sync encryption is enabled, identity data is
access to your computer could read it. This is the same as browser cookies AES-256 encrypted at rest — unreadable without your password.</span>
and localStorage, not as secure as saved passwords (which are OS-encrypted).
</div> </div>
<div class="privacy-note" style="color:#b45309;background:#fef3c7;padding:6px 8px;border-radius:4px;margin-bottom:6px"> <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 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 SecretScanner from '../lib/secret-scanner.js';
import AutoDetect from '../lib/auto-detect.js'; import AutoDetect from '../lib/auto-detect.js';
import Storage from '../lib/storage.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'; import api from '../lib/browser-polyfill.js';
// --- State --- // --- State ---
@@ -18,6 +20,20 @@ const $$ = (sel) => document.querySelectorAll(sel);
// --- Init --- // --- Init ---
document.addEventListener('DOMContentLoaded', async () => { 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(); mappings = await Storage.getMappings();
profiles = await Storage.getProfiles(); profiles = await Storage.getProfiles();
identity = await Storage.getIdentity(); identity = await Storage.getIdentity();
@@ -191,7 +207,97 @@ document.addEventListener('DOMContentLoaded', async () => {
e.preventDefault(); e.preventDefault();
api.runtime.openOptionsPage(); 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 --- // --- Profiles ---
function renderProfileSelector() { function renderProfileSelector() {