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
+34 -7
View File
@@ -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