Files
silent-send/src/lib/storage.js
T
Claude a22ba549a2 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
2026-03-26 19:03:54 +00:00

350 lines
10 KiB
JavaScript

/**
* Silent Send - Storage Manager
*
* 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',
IDENTITY: 'ss_identity',
LOG: 'ss_activity_log',
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,
revealMode: false,
secretScanning: true,
autoDetect: true,
autoRedactDetected: true,
autoAddDetected: true,
maxLogEntries: 200,
customDomains: [],
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'],
browserSync: false,
};
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 data = await this._readSecure(KEYS.MAPPINGS);
return data || [];
},
async saveMappings(mappings) {
await this._writeSecure(KEYS.MAPPINGS, mappings, { ss_lastModified: Date.now() });
},
async addMapping(mapping) {
const mappings = await this.getMappings();
const newMapping = {
id: crypto.randomUUID(),
real: mapping.real || '',
substitute: mapping.substitute || '',
category: mapping.category || 'general',
caseSensitive: mapping.caseSensitive ?? false,
enabled: mapping.enabled ?? true,
createdAt: Date.now(),
};
mappings.push(newMapping);
await this.saveMappings(mappings);
return newMapping;
},
async updateMapping(id, updates) {
const mappings = await this.getMappings();
const idx = mappings.findIndex((m) => m.id === id);
if (idx === -1) return null;
mappings[idx] = { ...mappings[idx], ...updates };
await this.saveMappings(mappings);
return mappings[idx];
},
async deleteMapping(id) {
const mappings = await this.getMappings();
const filtered = mappings.filter((m) => m.id !== id);
await this.saveMappings(filtered);
},
// --- Identity Profiles (Smart Patterns) ---
_emptyProfile() {
return {
id: crypto.randomUUID(),
name: 'Personal',
active: true,
emails: [],
names: [],
usernames: [],
hostnames: [],
phones: [],
catchAllEmail: '',
emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
},
async getProfiles() {
const data = await this._readSecure(KEYS.IDENTITY);
if (data?.profiles) return data.profiles;
return [];
},
async saveProfiles(profiles) {
await this._writeSecure(KEYS.IDENTITY, { profiles }, { ss_lastModified: Date.now() });
},
async addProfile(name) {
const profiles = await this.getProfiles();
const profile = { ...this._emptyProfile(), name: name || `Profile ${profiles.length + 1}` };
profiles.push(profile);
await this.saveProfiles(profiles);
return profile;
},
async updateProfile(id, updates) {
const profiles = await this.getProfiles();
const idx = profiles.findIndex((p) => p.id === id);
if (idx === -1) return null;
profiles[idx] = { ...profiles[idx], ...updates };
await this.saveProfiles(profiles);
return profiles[idx];
},
async deleteProfile(id) {
const profiles = await this.getProfiles();
const filtered = profiles.filter((p) => p.id !== id);
await this.saveProfiles(filtered);
},
// Merged identity: combines all active profiles into one identity object
// for the substitution engine (which expects a single identity)
async getIdentity() {
const profiles = await this.getProfiles();
const active = profiles.filter((p) => p.active);
if (active.length === 0) {
return { emails: [], names: [], usernames: [], hostnames: [], phones: [],
catchAllEmail: '', emailDomains: [], enabled: { emails: true, names: true, usernames: true, phones: true, paths: true } };
}
// Merge all active profiles
const merged = {
emails: [],
names: [],
usernames: [],
hostnames: [],
phones: [],
catchAllEmail: '',
emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
for (const p of active) {
merged.emails.push(...(p.emails || []));
merged.names.push(...(p.names || []));
merged.usernames.push(...(p.usernames || []));
merged.hostnames.push(...(p.hostnames || []));
merged.phones.push(...(p.phones || []));
if (p.catchAllEmail && !merged.catchAllEmail) merged.catchAllEmail = p.catchAllEmail;
merged.emailDomains.push(...(p.emailDomains || []));
}
return merged;
},
// Legacy compat: saveIdentity saves to first profile
async saveIdentity(identity) {
const profiles = await this.getProfiles();
if (profiles.length === 0) {
const profile = { ...this._emptyProfile(), ...identity };
await this.saveProfiles([profile]);
} else {
profiles[0] = { ...profiles[0], ...identity };
await this.saveProfiles(profiles);
}
},
// --- Activity Log ---
async getLog() {
const data = await this._readSecure(KEYS.LOG);
return data || [];
},
async addLogEntry(entry) {
const settings = await this.getSettings();
const log = await this.getLog();
log.unshift({
...entry,
id: crypto.randomUUID(),
timestamp: Date.now(),
});
// Trim to max entries
if (log.length > settings.maxLogEntries) {
log.length = settings.maxLogEntries;
}
await this._writeSecure(KEYS.LOG, log);
},
async clearLog() {
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);
return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) };
},
async saveSettings(settings) {
const current = await this.getSettings();
await api.storage.local.set({
[KEYS.SETTINGS]: { ...current, ...settings },
ss_lastModified: Date.now(),
});
},
};
if (typeof globalThis !== 'undefined') {
globalThis.SilentSendStorage = Storage;
}
export default Storage;