Merge pull request #8 from outis1one/claude/read-repo-wA3y1

Claude/read repo w a3y1
This commit is contained in:
Outis
2026-03-26 15:11:07 -04:00
committed by GitHub
10 changed files with 873 additions and 152 deletions
+64 -10
View File
@@ -79,12 +79,14 @@ You can create multiple profiles (Personal, Work, Spouse) using the dropdown at
### Icon colors
| Icon color | Meaning |
|-----------|---------|
| **Gray** | Not configured — does nothing until you set up your identity |
| **Black** | Active and protecting |
| **Blue** | Reveal mode on — showing your real data in AI responses |
| **Red** | Manually disabled |
| Icon color | Badge | Meaning |
|-----------|-------|---------|
| **Gray** | | Not configured — does nothing until you set up your identity |
| **Black** | | Active and protecting |
| **Blue** | | Reveal mode on — showing your real data in AI responses |
| **Red** | | Manually disabled |
| Any | **LOCK** (red) | Vault locked — encrypted data, needs password to unlock |
| Any | **SYN** (purple) | New settings synced from another device |
### Reveal mode
@@ -261,17 +263,69 @@ src/
substitution-engine.js — Core explicit find/replace logic
smart-patterns.js — Auto-detection of emails, names, usernames, hostnames, phones, paths
secret-scanner.js — Auto-detection of API keys, tokens, passwords, SSNs, credit cards
storage.js — Browser storage wrapper
crypto.js AES-256-GCM encryption, PBKDF2 key derivation, TOTP (RFC 6238), WebAuthn, key caching
sync.js — Cross-browser sync with encryption (browser sync, Gist, folder, URL, sync codes)
storage.js — Browser storage wrapper with transparent at-rest encryption
browser-polyfill.js — Chrome/Firefox API compatibility
```
## Privacy
## Privacy & Security
- All data stays local in browser storage
- No external servers, no telemetry, no analytics
- All data stays local in browser storage — no external servers, no telemetry, no analytics
- The extension only activates on supported AI sites (and any custom domains you add)
- Your real identity data never leaves your machine
### At-rest encryption
When you enable **sync encryption** (Options → Sync Between Browsers → Sync Encryption), all sensitive data is AES-256-GCM encrypted before being written to browser storage:
| What's encrypted | Contains |
|---|---|
| Identity profiles | Real names, emails, usernames, hostnames, phones + substitutes |
| Mappings | All real → substitute pairs |
| Activity log | History of what was substituted |
| Settings | Custom domains, configuration preferences |
| TOTP secret | Authenticator app shared secret |
| All sync data | Everything sent to Gist, sync folders, custom URLs, browser sync |
**Only two things remain plaintext** — the encryption salt (needed to derive the key) and a verification blob (needed to check the password). Neither contains PPI.
Without at-rest encryption, data is stored in plaintext in the browser's local storage (similar to cookies and localStorage). Anyone with file system access to your browser profile directory could read it.
### Vault unlock
After a browser restart, the extension is in a **locked** state:
1. Badge shows **LOCK** in red — substitutions are paused
2. Click the extension icon to see the unlock prompt
3. Enter your password (first time per device), or use biometric/TOTP for re-verification
4. Protection resumes immediately across all open tabs
This is similar to how password managers work — your vault is locked until you authenticate.
### Authentication options
| Method | When it's used |
|---|---|
| **Password** | Required once per device to derive the encryption key. The key is then cached indefinitely in IndexedDB. |
| **TOTP** | Optional second factor alongside password. Can also be used alone for re-verification after the key is cached. |
| **WebAuthn (biometric/PIN)** | Primary re-verification method after first setup. Uses fingerprint, Face ID, or Windows Hello. |
Re-verification (biometric, TOTP, or password) is only triggered when the configurable TTL expires (default: 90 days) **and** new sync data exists. If nothing changed, you're never prompted.
### Cross-device sync encryption
All sync channels (browser sync, GitHub Gist, folder sync, custom URL, sync codes) encrypt data before sending. A new device bootstraps itself from the encrypted sync payload:
1. Pull encrypted data from any sync channel
2. Enter the same password used on the original device (once)
3. Full configuration (including TOTP secret) is restored from the encrypted payload
4. WebAuthn credential is registered locally for future re-verification
### Smart reveal
Reveal mode only replaces values that were **actually substituted** in outbound messages during the current session. If the AI uses a word that happens to match one of your substitute values (e.g., the AI says "the user should..." and "user" is a configured substitute), it won't be falsely revealed as your real username.
## Disclaimer
**Silent Send is not a battle-tested privacy application.** It is a convenience tool that reduces the chance of accidentally sharing personal information with AI services. It should not be relied upon as your sole privacy protection.
+46
View File
@@ -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
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
+78 -28
View File
@@ -242,29 +242,37 @@ const SilentSendCrypto = {
},
/**
* Cache a CryptoKey with a TTL.
* Cache a CryptoKey persistently.
* The key stays in IndexedDB indefinitely — it's only cleared if
* the user explicitly disables encryption or clears browser data.
* The TTL controls when re-verification (via WebAuthn) is required,
* NOT when the key is deleted.
*
* @param {CryptoKey} key
* @param {string} salt - base64-encoded salt used to derive this key
* @param {number} ttlDays - 0 = session only, -1 = never expire
* @param {number} ttlDays - re-verify interval: 0 = each session, -1 = never
*/
async cacheKey(key, salt, ttlDays = 90) {
const db = await this._openCacheDB();
const expiresAt = ttlDays === -1
? -1 // never
const reverifyAt = ttlDays === -1
? -1 // never re-verify
: ttlDays === 0
? 0 // session only (cleared on browser restart by the caller)
? 0 // re-verify each session
: Date.now() + ttlDays * 86400000;
return new Promise((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put({ key, salt, expiresAt, cachedAt: Date.now() }, 'syncKey');
tx.objectStore('keys').put({ key, salt, reverifyAt, cachedAt: Date.now() }, 'syncKey');
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
},
/**
* Retrieve cached key if not expired.
* Retrieve cached key. The key is always returned if it exists —
* it never expires or self-deletes. Use needsReverification() to
* check if the user should re-verify via WebAuthn.
*
* Returns { key: CryptoKey, salt: string } or null.
*/
async getCachedKey() {
@@ -275,22 +283,8 @@ const SilentSendCrypto = {
const req = tx.objectStore('keys').get('syncKey');
req.onsuccess = () => {
const entry = req.result;
if (!entry) return resolve(null);
// Check expiry
if (entry.expiresAt === -1) {
// Never expires
resolve({ key: entry.key, salt: entry.salt });
} else if (entry.expiresAt === 0) {
// Session only — always valid until explicitly cleared
resolve({ key: entry.key, salt: entry.salt });
} else if (Date.now() < entry.expiresAt) {
resolve({ key: entry.key, salt: entry.salt });
} else {
// Expired — clean up
this.clearCachedKey();
resolve(null);
}
if (!entry?.key) return resolve(null);
resolve({ key: entry.key, salt: entry.salt });
};
req.onerror = () => resolve(null);
});
@@ -299,6 +293,60 @@ const SilentSendCrypto = {
}
},
/**
* Check if the cached key needs re-verification (TTL expired).
* This does NOT delete the key — it just signals that the user
* should re-verify via WebAuthn/biometric before using it.
*/
async needsReverification() {
try {
const db = await this._openCacheDB();
return new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('syncKey');
req.onsuccess = () => {
const entry = req.result;
if (!entry) return resolve(false); // no key = nothing to re-verify
if (entry.reverifyAt === -1) return resolve(false); // never
if (entry.reverifyAt === 0) return resolve(true); // each session
resolve(Date.now() >= entry.reverifyAt);
};
req.onerror = () => resolve(false);
});
} catch {
return false;
}
},
/**
* Mark the cached key as freshly verified (resets the TTL timer).
*/
async markVerified(ttlDays = 90) {
try {
const db = await this._openCacheDB();
const entry = await new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('syncKey');
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
if (!entry) return;
entry.reverifyAt = ttlDays === -1
? -1
: ttlDays === 0
? 0
: Date.now() + ttlDays * 86400000;
await new Promise((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put(entry, 'syncKey');
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch { /* non-fatal */ }
},
async clearCachedKey() {
try {
const db = await this._openCacheDB();
@@ -312,12 +360,14 @@ const SilentSendCrypto = {
},
// ----------------------------------------------------------------
// WebAuthn — biometric/PIN unlock as re-authentication gate
// WebAuthn — biometric/PIN as PRIMARY re-authentication
//
// On first setup, we create a credential tied to this origin.
// On re-auth, we verify the credential — if it passes, we release
// the cached key. WebAuthn doesn't produce an encryption key;
// it gates access to the IndexedDB-cached CryptoKey.
// On first setup (per device), the user enters their password once
// to derive the encryption key. A WebAuthn credential is registered
// on that device. From then on, ALL re-verification uses biometrics
// — the password is never needed again on that device unless
// IndexedDB is cleared. The CryptoKey persists indefinitely in
// IndexedDB; WebAuthn just gates access when TTL expires.
// ----------------------------------------------------------------
/**
+148 -16
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,13 @@ const KEYS = {
SETTINGS: 'ss_settings',
};
// Keys that contain sensitive PPI and should be encrypted at rest
// All user data keys are encrypted at rest — settings included since
// custom domains and configuration can reveal what services the user
// accesses. Only ss_sync_encryption (salt, verification blob) and
// ss_lastModified stay plaintext for bootstrap purposes.
const ENCRYPTED_KEYS = new Set([KEYS.MAPPINGS, KEYS.IDENTITY, KEYS.LOG, KEYS.SETTINGS]);
const DEFAULT_SETTINGS = {
enabled: true,
showHighlights: false,
@@ -29,15 +45,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 +219,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 +302,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,26 +321,30 @@ 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 encrypted at rest (custom domains can reveal which
// private AI services the user accesses). When locked, defaults are
// returned so the extension can show basic UI.
async getSettings() {
const result = await api.storage.local.get(KEYS.SETTINGS);
return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) };
const data = await this._readSecure(KEYS.SETTINGS);
return { ...DEFAULT_SETTINGS, ...(data || {}) };
},
async saveSettings(settings) {
const current = await this.getSettings();
await api.storage.local.set({
[KEYS.SETTINGS]: { ...current, ...settings },
ss_lastModified: Date.now(),
});
await this._writeSecure(
KEYS.SETTINGS,
{ ...current, ...settings },
{ ss_lastModified: Date.now() },
);
},
};
+256 -49
View File
@@ -43,40 +43,88 @@ 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 });
},
/**
* Obtain the encryption key — from cache, WebAuthn, or requires password.
* Returns { key, salt } or null if auth is required.
* 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;
}
},
/**
* Obtain the encryption key for sync operations.
*
* The caller should handle null by prompting the user.
* The key persists in IndexedDB indefinitely once the password is
* entered (once per device). Re-verification via WebAuthn/biometric
* is triggered by the TTL timer — but the key is NEVER deleted.
*
* Flow:
* 1. Check cached key — if exists and no re-verification needed, return it
* 2. If re-verification needed and WebAuthn is set up, prompt biometric
* 3. If no cached key at all, password is needed (first time on this device)
*
* Returns { key, salt } or null if password entry is required.
*/
async _getEncryptionKey() {
const config = await this._getSyncEncryption();
if (!config?.enabled) return null;
// Try cached key first
const cached = await SilentSendCrypto.getCachedKey();
if (cached) return cached;
// Try WebAuthn re-auth if configured
if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCredential = await SilentSendCrypto.hasWebAuthnCredential();
if (hasCredential) {
const verified = await SilentSendCrypto.webAuthnAuthenticate();
if (verified) {
// WebAuthn passed — but we need the actual key.
// The key must have been cached previously (before expiry triggered re-auth).
// If it's gone from cache, we need the password again.
// Check if we stored a wrapped version for WebAuthn recovery.
const wrapped = await this._getWrappedKey();
if (wrapped) return wrapped;
if (cached) {
// Key exists — check if re-verification is needed
const needsReverify = await SilentSendCrypto.needsReverification();
if (!needsReverify) {
return cached; // Key valid, no re-verification needed
}
// TTL expired — try WebAuthn as primary re-auth
if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCredential = await SilentSendCrypto.hasWebAuthnCredential();
if (hasCredential) {
const verified = await SilentSendCrypto.webAuthnAuthenticate();
if (verified) {
// Biometric passed — reset the TTL timer and return the key
await SilentSendCrypto.markVerified(config.ttlDays ?? 90);
return cached;
}
}
}
// WebAuthn not available or failed — still return the key but
// signal that re-verification is pending (the UI can prompt)
// For sync operations, we allow the key to be used — the data
// is already on this device. Re-verification is a UX gate, not
// a security boundary (the key is in IndexedDB regardless).
return cached;
}
// No cached key, no WebAuthn recovery — password needed
// No cached key at all — password needed (first time on this device)
return null;
},
@@ -119,7 +167,9 @@ const SilentSendSync = {
/**
* Authenticate with password (+ optional TOTP) and cache the key.
* Called from the UI after prompting the user.
* Called from the UI — typically only needed ONCE per device.
* After this, the key persists in IndexedDB and re-verification
* can use WebAuthn or TOTP alone.
*
* @param {string} password
* @param {string} [totpCode] — required if TOTP is configured
@@ -129,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);
@@ -148,18 +191,117 @@ const SilentSendSync = {
}
}
// Cache the key
// 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);
// Store wrapped key for WebAuthn recovery
if (config.webauthn) {
await this._storeWrappedKey(key, salt);
// Store key for WebAuthn-gated access
await this._storeWrappedKey(key, salt);
// Register WebAuthn credential if enabled and not yet registered
if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
if (!hasCred) {
try {
await SilentSendCrypto.webAuthnRegister();
} catch { /* non-fatal — biometric just won't be available */ }
}
}
return { success: true };
},
/**
* Re-verify identity using TOTP code alone (no password needed).
* Only works when the key is already cached (not first-device setup).
* Resets the TTL timer on success.
*
* @param {string} totpCode — 6-digit TOTP code
* @returns {{ success: boolean, reason?: string }}
*/
async reverifyWithTOTP(totpCode) {
const config = await this._getSyncEncryption();
if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' };
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.' };
// 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
await SilentSendCrypto.markVerified(config.ttlDays ?? 90);
return { success: true };
},
/**
* Re-verify identity using password alone (no TOTP needed).
* Only works when the key is already cached (not first-device setup).
* Resets the TTL timer on success.
*
* @param {string} password
* @returns {{ success: boolean, reason?: string }}
*/
async reverifyWithPassword(password) {
const config = await this._getSyncEncryption();
if (!config?.enabled) return { success: false, reason: 'Encryption not enabled.' };
// Must have a cached key
const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return { success: false, reason: 'No cached key. Full authentication required.' };
// Verify password against the verification blob
const { key } = await SilentSendCrypto.deriveAndReturnKey(password, config.salt);
if (config.verificationBlob) {
try {
await SilentSendCrypto.decryptWithKey(config.verificationBlob, key);
} catch {
return { success: false, reason: 'Wrong password.' };
}
}
// Reset the TTL timer
await SilentSendCrypto.markVerified(config.ttlDays ?? 90);
return { success: true };
},
/**
* Check if re-verification is needed (TTL expired but key exists).
* Different from needsAuth() which checks if the key is missing entirely.
*/
async needsReverification() {
const config = await this._getSyncEncryption();
if (!config?.enabled) return false;
const cached = await SilentSendCrypto.getCachedKey();
if (!cached) return false; // no key = needs full auth, not re-verify
return SilentSendCrypto.needsReverification();
},
/**
* Set up sync encryption for the first time.
* @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }}
@@ -187,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 {
@@ -212,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 };
},
@@ -219,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();
@@ -233,27 +384,34 @@ const SilentSendSync = {
},
/**
* Check if authentication is needed (key cache expired).
* Check if password entry is needed (no cached key on this device).
* This is only true when the device has never been set up — once the
* password is entered, the key persists indefinitely in IndexedDB.
* Re-verification (via WebAuthn) is handled transparently by
* _getEncryptionKey() and doesn't require user interaction here.
*/
async needsAuth() {
const config = await this._getSyncEncryption();
if (!config?.enabled) return false;
const cached = await SilentSendCrypto.getCachedKey();
if (cached) return false;
// Try WebAuthn silently
if (config.webauthn) {
const wrapped = await this._getWrappedKey();
if (wrapped) return false; // WebAuthn can recover
}
if (cached) return false; // key exists — WebAuthn handles re-verify
return true;
},
/**
* Encrypt data for sync if encryption is enabled.
* Returns the original data if encryption is not enabled or key unavailable.
* Embeds the encryption config (salt, TOTP secret, etc.) into the
* encrypted payload so a new device can bootstrap itself from just
* the sync data + the password.
*
* Outer envelope (plaintext): { _ssEncrypted, payload, version, lastModified, _encConfig }
* - _encConfig contains salt and verificationBlob (needed to derive key on new device)
* - Everything else (TOTP secret, settings) is inside the encrypted payload
*
* Inner payload (encrypted): { ...syncData, _encMeta }
* - _encMeta contains the full encryption config including TOTP secret
*/
async _encryptForSync(data) {
const config = await this._getSyncEncryption();
@@ -261,17 +419,33 @@ const SilentSendSync = {
const keyInfo = await this._getEncryptionKey();
if (!keyInfo) {
// Key not available — caller should prompt for auth
return { data: null, encrypted: false, needsAuth: true };
}
const encryptedPayload = await SilentSendCrypto.encryptWithKey(data, keyInfo.key);
// Embed encryption config inside the encrypted payload
// so new devices can bootstrap their local config after decryption
const innerData = {
...data,
_encMeta: {
authMethod: config.authMethod,
ttlDays: config.ttlDays,
webauthn: config.webauthn,
totpSecret: await this._getTOTPSecret(config) || null,
},
};
const encryptedPayload = await SilentSendCrypto.encryptWithKey(innerData, keyInfo.key);
return {
data: {
_ssEncrypted: true,
payload: encryptedPayload,
version: data.version,
lastModified: data.lastModified,
// Plaintext bootstrap info — needed to derive the key on a new device
_encConfig: {
salt: config.salt,
verificationBlob: config.verificationBlob,
},
},
encrypted: true,
};
@@ -279,17 +453,50 @@ const SilentSendSync = {
/**
* Decrypt sync data if it's encrypted.
* Returns the original data if not encrypted.
* On a new device (no local encryption config), uses the _encConfig
* from the sync envelope to bootstrap. After decryption, restores
* the full encryption config from the inner _encMeta.
*/
async _decryptFromSync(data) {
if (!data?._ssEncrypted) return { data, decrypted: false };
// If this device has no encryption config yet, bootstrap from the sync envelope
let config = await this._getSyncEncryption();
if (!config?.enabled && data._encConfig) {
// Save minimal config so authenticate() can work
config = {
enabled: true,
salt: data._encConfig.salt,
verificationBlob: data._encConfig.verificationBlob,
authMethod: 'password', // will be updated from _encMeta after decryption
ttlDays: 90,
webauthn: false,
};
await this._saveSyncEncryption(config);
}
const keyInfo = await this._getEncryptionKey();
if (!keyInfo) {
return { data: null, decrypted: false, needsAuth: true };
}
const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key);
// Restore full encryption config from inner metadata
if (decrypted._encMeta) {
const fullConfig = await this._getSyncEncryption();
if (fullConfig) {
fullConfig.authMethod = decrypted._encMeta.authMethod || fullConfig.authMethod;
fullConfig.ttlDays = decrypted._encMeta.ttlDays ?? fullConfig.ttlDays;
fullConfig.webauthn = decrypted._encMeta.webauthn ?? fullConfig.webauthn;
if (decrypted._encMeta.totpSecret) {
fullConfig.totpSecret = decrypted._encMeta.totpSecret;
}
await this._saveSyncEncryption(fullConfig);
}
delete decrypted._encMeta;
}
return { data: decrypted, decrypted: true };
},
+15 -4
View File
@@ -160,16 +160,27 @@
</div>
</div>
<!-- Auth prompt (shown when key cache expired and sync needs decryption) -->
<!-- Auth prompt — first-device setup (password + optional TOTP) -->
<div id="syncAuthPrompt" style="display:none;margin-top:10px;padding:10px;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px">
<p style="font-size:12px;font-weight:500;margin:0 0 8px;color:#92400e">Authentication required — new sync data is available</p>
<p style="font-size:12px;font-weight:500;margin:0 0 8px;color:#92400e">Authentication required</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<input type="password" id="syncAuthPassword" placeholder="Password" autocomplete="current-password"
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<input type="text" id="syncAuthTOTP" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
<input type="text" id="syncAuthTOTPForPassword" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
style="width:80px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;display:none">
<button class="btn btn-primary btn-sm" id="btnSyncAuth">Unlock</button>
<button class="btn btn-sm" id="btnSyncAuthBiometric" style="display:none">Biometric</button>
</div>
<!-- Re-verify alternatives (shown when key exists but TTL expired) -->
<div id="reverifyOptions" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid #fde68a">
<p style="font-size:11px;color:#92400e;margin:0 0 6px">Or re-verify with any one of these:</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<button class="btn btn-sm" id="btnSyncAuthBiometric" style="display:none">Biometric / PIN</button>
<div id="totpReverifyGroup" style="display:none;display:flex;gap:4px;align-items:end">
<input type="text" id="syncAuthTOTP" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
style="width:80px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-sm" id="btnSyncAuthTOTP">Verify</button>
</div>
</div>
</div>
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
</div>
+102 -34
View File
@@ -869,45 +869,67 @@ async function initSyncEncryptionUI() {
}
});
// Auth prompt — Unlock button
// Auth prompt — Unlock with password (first-device or re-verify)
$('#btnSyncAuth').addEventListener('click', async () => {
const password = $('#syncAuthPassword').value;
const totpCode = $('#syncAuthTOTP').value;
const totpCode = $('#syncAuthTOTPForPassword').value;
if (!password) {
setSyncAuthStatus('Enter your password.', 'warn');
return;
}
const result = await SilentSendSync.authenticate(password, totpCode || undefined);
// Check if this is re-verification (key exists) or first-device (needs full auth)
const cached = await SilentSendCrypto.getCachedKey();
let result;
if (cached) {
// Re-verification — password alone is enough
result = await SilentSendSync.reverifyWithPassword(password);
} else {
// First device — full auth with password + TOTP if configured
result = await SilentSendSync.authenticate(password, totpCode || undefined);
}
if (result.success) {
$('#syncAuthPrompt').style.display = 'none';
$('#syncAuthPassword').value = '';
$('#syncAuthTOTP').value = '';
setSyncEncStatus('Authenticated. Sync data unlocked.', 'ok');
$('#syncAuthTOTPForPassword').value = '';
setSyncEncStatus(cached ? 'Re-verified with password.' : 'Authenticated. Sync data unlocked.', 'ok');
} else {
setSyncAuthStatus(result.reason, 'error');
}
});
// Auth prompt — Biometric button
// Re-verify with TOTP alone (key must already exist)
$('#btnSyncAuthTOTP').addEventListener('click', async () => {
const totpCode = $('#syncAuthTOTP').value;
if (!totpCode || totpCode.length < 6) {
setSyncAuthStatus('Enter your 6-digit TOTP code.', 'warn');
return;
}
const result = await SilentSendSync.reverifyWithTOTP(totpCode);
if (result.success) {
$('#syncAuthPrompt').style.display = 'none';
$('#syncAuthTOTP').value = '';
setSyncEncStatus('Re-verified with TOTP.', 'ok');
} else {
setSyncAuthStatus(result.reason, 'error');
}
});
// Re-verify with biometric/PIN (key must already exist)
$('#btnSyncAuthBiometric').addEventListener('click', async () => {
setSyncAuthStatus('Waiting for biometric...', 'neutral');
const verified = await SilentSendCrypto.webAuthnAuthenticate();
if (verified) {
// Try to recover wrapped key
const wrapped = await SilentSendSync._getWrappedKey();
if (wrapped) {
const config = await SilentSendSync._getSyncEncryption();
const ttlDays = config?.ttlDays ?? 90;
await SilentSendCrypto.cacheKey(wrapped.key, wrapped.salt, ttlDays);
$('#syncAuthPrompt').style.display = 'none';
setSyncEncStatus('Authenticated via biometric. Sync data unlocked.', 'ok');
} else {
setSyncAuthStatus('Biometric verified but key not found. Enter password.', 'warn');
}
const config = await SilentSendSync._getSyncEncryption();
const ttlDays = config?.ttlDays ?? 90;
await SilentSendCrypto.markVerified(ttlDays);
$('#syncAuthPrompt').style.display = 'none';
setSyncEncStatus('Re-verified via biometric.', 'ok');
} else {
setSyncAuthStatus('Biometric verification failed.', 'error');
setSyncAuthStatus('Biometric failed. Try TOTP or password.', 'error');
}
});
}
@@ -923,20 +945,26 @@ async function showEncryptionConfigured() {
else if (config.authMethod === 'totp') parts.push('TOTP');
else parts.push('Password');
if (config.webauthn) parts.push('Biometric');
if (config.webauthn) parts.push('Biometric re-auth');
const ttl = config.ttlDays === -1 ? 'never re-auth'
: config.ttlDays === 0 ? 'each session'
: `every ${config.ttlDays}d`;
const ttl = config.ttlDays === -1 ? 'never re-verify'
: config.ttlDays === 0 ? 're-verify each session'
: `re-verify every ${config.ttlDays}d`;
parts.push(ttl);
$('#encryptionInfo').textContent = `(${parts.join(' · ')})`;
}
// Check if auth is currently needed
// Check if password entry is needed (first time on this device)
const needsAuth = await SilentSendSync.needsAuth();
if (needsAuth) {
showSyncAuthPrompt();
showSyncAuthPrompt('first-device');
} else {
// Key exists — check if re-verification is needed
const needsReverify = await SilentSendSync.needsReverification();
if (needsReverify) {
showSyncAuthPrompt('reverify');
}
}
}
@@ -947,22 +975,62 @@ function showEncryptionNotConfigured() {
$('#totpSetupResult').style.display = 'none';
}
async function showSyncAuthPrompt() {
/**
* Show the auth prompt.
* @param {'first-device'|'reverify'|'decrypt'} mode
*
* first-device: No cached key — password (+ TOTP if configured) required.
* reverify: Key exists but TTL expired — any ONE of: biometric / TOTP / password.
* decrypt: Encrypted data arrived — same as first-device if no key, reverify if key exists.
*/
async function showSyncAuthPrompt(mode = 'decrypt') {
const config = await SilentSendSync._getSyncEncryption();
$('#syncAuthPrompt').style.display = 'block';
const promptEl = $('#syncAuthPrompt');
promptEl.style.display = 'block';
// Show TOTP field if needed
if (config?.totpSecret) {
$('#syncAuthTOTP').style.display = '';
const isReverify = (mode === 'reverify') ||
(mode === 'decrypt' && await SilentSendCrypto.getCachedKey());
// Adjust header message
const headerEl = promptEl.querySelector('p');
if (mode === 'first-device') {
headerEl.textContent = 'First time on this device — enter your sync encryption password';
} else if (isReverify) {
headerEl.textContent = 'Re-verification required — use any method below';
} else {
$('#syncAuthTOTP').style.display = 'none';
headerEl.textContent = 'First time on this device — enter your sync encryption password';
}
// Show biometric button if available
if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
$('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none';
// First-device: show TOTP alongside password if configured
const hasTOTP = config?.totpSecret || config?._totpEncrypted;
if (!isReverify && hasTOTP) {
$('#syncAuthTOTPForPassword').style.display = '';
} else {
$('#syncAuthTOTPForPassword').style.display = 'none';
}
// Re-verify alternatives section
const reverifyOpts = $('#reverifyOptions');
if (isReverify) {
reverifyOpts.style.display = 'block';
// Biometric button
if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
$('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none';
} else {
$('#btnSyncAuthBiometric').style.display = 'none';
}
// TOTP re-verify option
const totpGroup = $('#totpReverifyGroup');
if (hasTOTP) {
totpGroup.style.display = 'flex';
} else {
totpGroup.style.display = 'none';
}
} else {
reverifyOpts.style.display = 'none';
$('#btnSyncAuthBiometric').style.display = 'none';
}
}
+23 -3
View File
@@ -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">&#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 -->
<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
View File
@@ -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() {