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
+136 -10
View File
@@ -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
View File
@@ -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,
},
};