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:
@@ -153,6 +153,44 @@ const messageHandlers = {
|
||||
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) {
|
||||
await Storage.saveSettings(message.settings);
|
||||
|
||||
@@ -372,6 +410,14 @@ api.runtime.onInstalled.addListener(async () => {
|
||||
const settings = await Storage.getSettings();
|
||||
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
|
||||
const stored = await api.storage.local.get('ss_sync_notification');
|
||||
if (stored.ss_sync_notification) {
|
||||
|
||||
+34
-7
@@ -58,11 +58,16 @@
|
||||
// Load mappings and settings, then inject into page
|
||||
async function init() {
|
||||
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 };
|
||||
|
||||
// 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
|
||||
const identityData = result.ss_identity || {};
|
||||
const identity = mergeProfiles(identityData);
|
||||
|
||||
// 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)
|
||||
// Skip encrypted blobs — background will send decrypted data via vault:unlocked
|
||||
api.storage.onChanged.addListener((changes) => {
|
||||
if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) {
|
||||
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;
|
||||
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
|
||||
api.runtime.onMessage.addListener((message) => {
|
||||
// Listen for settings updates and vault unlock from background
|
||||
api.runtime.onMessage.addListener(async (message) => {
|
||||
if (message.type === 'settings:updated') {
|
||||
window.postMessage({
|
||||
type: 'ss:config-updated',
|
||||
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
|
||||
|
||||
+136
-10
@@ -3,9 +3,18 @@
|
||||
*
|
||||
* Wraps browser/api.storage.local with typed helpers for mappings,
|
||||
* 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 SilentSendCrypto from './crypto.js';
|
||||
|
||||
const KEYS = {
|
||||
MAPPINGS: 'ss_mappings',
|
||||
@@ -14,6 +23,9 @@ const KEYS = {
|
||||
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 = {
|
||||
enabled: true,
|
||||
showHighlights: false,
|
||||
@@ -29,15 +41,128 @@ const DEFAULT_SETTINGS = {
|
||||
};
|
||||
|
||||
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 ---
|
||||
|
||||
async getMappings() {
|
||||
const result = await api.storage.local.get(KEYS.MAPPINGS);
|
||||
return result[KEYS.MAPPINGS] || [];
|
||||
const data = await this._readSecure(KEYS.MAPPINGS);
|
||||
return data || [];
|
||||
},
|
||||
|
||||
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) {
|
||||
@@ -90,14 +215,13 @@ const Storage = {
|
||||
},
|
||||
|
||||
async getProfiles() {
|
||||
const result = await api.storage.local.get(KEYS.IDENTITY);
|
||||
const data = result[KEYS.IDENTITY];
|
||||
const data = await this._readSecure(KEYS.IDENTITY);
|
||||
if (data?.profiles) return data.profiles;
|
||||
return [];
|
||||
},
|
||||
|
||||
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) {
|
||||
@@ -174,8 +298,8 @@ const Storage = {
|
||||
// --- Activity Log ---
|
||||
|
||||
async getLog() {
|
||||
const result = await api.storage.local.get(KEYS.LOG);
|
||||
return result[KEYS.LOG] || [];
|
||||
const data = await this._readSecure(KEYS.LOG);
|
||||
return data || [];
|
||||
},
|
||||
|
||||
async addLogEntry(entry) {
|
||||
@@ -193,14 +317,16 @@ const Storage = {
|
||||
log.length = settings.maxLogEntries;
|
||||
}
|
||||
|
||||
await api.storage.local.set({ [KEYS.LOG]: log });
|
||||
await this._writeSecure(KEYS.LOG, log);
|
||||
},
|
||||
|
||||
async clearLog() {
|
||||
await api.storage.local.set({ [KEYS.LOG]: [] });
|
||||
await this._writeSecure(KEYS.LOG, []);
|
||||
},
|
||||
|
||||
// --- 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() {
|
||||
const result = await api.storage.local.get(KEYS.SETTINGS);
|
||||
|
||||
+69
-18
@@ -43,7 +43,36 @@ const SilentSendSync = {
|
||||
},
|
||||
|
||||
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();
|
||||
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
|
||||
// Derive key from password first (needed to decrypt TOTP secret)
|
||||
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) {
|
||||
try {
|
||||
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)
|
||||
const ttlDays = config.ttlDays ?? 90;
|
||||
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
|
||||
@@ -200,14 +238,18 @@ const SilentSendSync = {
|
||||
async reverifyWithTOTP(totpCode) {
|
||||
const config = await this._getSyncEncryption();
|
||||
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
|
||||
const cached = await SilentSendCrypto.getCachedKey();
|
||||
if (!cached) return { success: false, reason: 'No cached key. Password required for first setup.' };
|
||||
|
||||
// Validate the TOTP code
|
||||
const valid = await SilentSendCrypto.validateTOTP(config.totpSecret, totpCode);
|
||||
// Decrypt the TOTP secret and validate
|
||||
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.' };
|
||||
|
||||
// Reset the TTL timer
|
||||
@@ -287,19 +329,20 @@ const SilentSendSync = {
|
||||
webauthn: enableWebAuthn,
|
||||
};
|
||||
|
||||
// Cache the key immediately (needed before _saveSyncEncryption
|
||||
// can encrypt the TOTP secret)
|
||||
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
|
||||
|
||||
let totpSecret, totpURI;
|
||||
if (enableTOTP) {
|
||||
totpSecret = SilentSendCrypto.generateTOTPSecret();
|
||||
totpURI = SilentSendCrypto.totpURI(totpSecret);
|
||||
config.totpSecret = totpSecret;
|
||||
config.totpSecret = totpSecret; // _saveSyncEncryption will encrypt this
|
||||
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 {
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -319,6 +366,10 @@ const SilentSendSync = {
|
||||
* Disable sync encryption entirely.
|
||||
*/
|
||||
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 SilentSendCrypto.clearCachedKey();
|
||||
await SilentSendCrypto.clearWebAuthnCredential();
|
||||
@@ -379,7 +430,7 @@ const SilentSendSync = {
|
||||
authMethod: config.authMethod,
|
||||
ttlDays: config.ttlDays,
|
||||
webauthn: config.webauthn,
|
||||
totpSecret: config.totpSecret || null,
|
||||
totpSecret: await this._getTOTPSecret(config) || null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1002,7 +1002,8 @@ async function showSyncAuthPrompt(mode = 'decrypt') {
|
||||
}
|
||||
|
||||
// First-device: show TOTP alongside password if configured
|
||||
if (!isReverify && config?.totpSecret) {
|
||||
const hasTOTP = config?.totpSecret || config?._totpEncrypted;
|
||||
if (!isReverify && hasTOTP) {
|
||||
$('#syncAuthTOTPForPassword').style.display = '';
|
||||
} else {
|
||||
$('#syncAuthTOTPForPassword').style.display = 'none';
|
||||
@@ -1023,7 +1024,7 @@ async function showSyncAuthPrompt(mode = 'decrypt') {
|
||||
|
||||
// TOTP re-verify option
|
||||
const totpGroup = $('#totpReverifyGroup');
|
||||
if (config?.totpSecret) {
|
||||
if (hasTOTP) {
|
||||
totpGroup.style.display = 'flex';
|
||||
} else {
|
||||
totpGroup.style.display = 'none';
|
||||
|
||||
+23
-3
@@ -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">🔒</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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user