Merge pull request #8 from outis1one/claude/read-repo-wA3y1
Claude/read repo w a3y1
This commit is contained in:
@@ -79,12 +79,14 @@ You can create multiple profiles (Personal, Work, Spouse) using the dropdown at
|
|||||||
|
|
||||||
### Icon colors
|
### Icon colors
|
||||||
|
|
||||||
| Icon color | Meaning |
|
| Icon color | Badge | Meaning |
|
||||||
|-----------|---------|
|
|-----------|-------|---------|
|
||||||
| **Gray** | Not configured — does nothing until you set up your identity |
|
| **Gray** | | Not configured — does nothing until you set up your identity |
|
||||||
| **Black** | Active and protecting |
|
| **Black** | | Active and protecting |
|
||||||
| **Blue** | Reveal mode on — showing your real data in AI responses |
|
| **Blue** | | Reveal mode on — showing your real data in AI responses |
|
||||||
| **Red** | Manually disabled |
|
| **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
|
### Reveal mode
|
||||||
|
|
||||||
@@ -261,17 +263,69 @@ src/
|
|||||||
substitution-engine.js — Core explicit find/replace logic
|
substitution-engine.js — Core explicit find/replace logic
|
||||||
smart-patterns.js — Auto-detection of emails, names, usernames, hostnames, phones, paths
|
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
|
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
|
browser-polyfill.js — Chrome/Firefox API compatibility
|
||||||
```
|
```
|
||||||
|
|
||||||
## Privacy
|
## Privacy & Security
|
||||||
|
|
||||||
- All data stays local in browser storage
|
- All data stays local in browser storage — no external servers, no telemetry, no analytics
|
||||||
- No external servers, no telemetry, no analytics
|
|
||||||
- The extension only activates on supported AI sites (and any custom domains you add)
|
- The extension only activates on supported AI sites (and any custom domains you add)
|
||||||
- Your real identity data never leaves your machine
|
- 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
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -153,6 +153,44 @@ const messageHandlers = {
|
|||||||
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
|
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) {
|
async 'update:settings'(message) {
|
||||||
await Storage.saveSettings(message.settings);
|
await Storage.saveSettings(message.settings);
|
||||||
|
|
||||||
@@ -372,6 +410,14 @@ api.runtime.onInstalled.addListener(async () => {
|
|||||||
const settings = await Storage.getSettings();
|
const settings = await Storage.getSettings();
|
||||||
await updateIcon(settings);
|
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
|
// 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');
|
const stored = await api.storage.local.get('ss_sync_notification');
|
||||||
if (stored.ss_sync_notification) {
|
if (stored.ss_sync_notification) {
|
||||||
|
|||||||
+33
-6
@@ -58,11 +58,16 @@
|
|||||||
// Load mappings and settings, then inject into page
|
// Load mappings and settings, then inject into page
|
||||||
async function init() {
|
async function init() {
|
||||||
const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']);
|
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 };
|
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
|
// Merge active profiles into a flat identity object for the content script
|
||||||
const identityData = result.ss_identity || {};
|
|
||||||
const identity = mergeProfiles(identityData);
|
const identity = mergeProfiles(identityData);
|
||||||
|
|
||||||
// Inject the main interception script into the page's world
|
// 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)
|
// 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) => {
|
api.storage.onChanged.addListener((changes) => {
|
||||||
if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) {
|
if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) {
|
||||||
const msg = { type: 'ss:config-updated' };
|
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;
|
if (changes.ss_settings) msg.settings = changes.ss_settings.newValue;
|
||||||
|
|
||||||
|
// Only post if we have something meaningful to send
|
||||||
|
if (msg.mappings || msg.identity || msg.settings) {
|
||||||
window.postMessage(msg, '*');
|
window.postMessage(msg, '*');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for settings updates from popup via runtime messages
|
// Listen for settings updates and vault unlock from background
|
||||||
api.runtime.onMessage.addListener((message) => {
|
api.runtime.onMessage.addListener(async (message) => {
|
||||||
if (message.type === 'settings:updated') {
|
if (message.type === 'settings:updated') {
|
||||||
window.postMessage({
|
window.postMessage({
|
||||||
type: 'ss:config-updated',
|
type: 'ss:config-updated',
|
||||||
settings: message.settings,
|
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
|
// Storage bridge — lets page world script read/write storage
|
||||||
|
|||||||
+77
-27
@@ -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 {CryptoKey} key
|
||||||
* @param {string} salt - base64-encoded salt used to derive this 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) {
|
async cacheKey(key, salt, ttlDays = 90) {
|
||||||
const db = await this._openCacheDB();
|
const db = await this._openCacheDB();
|
||||||
const expiresAt = ttlDays === -1
|
const reverifyAt = ttlDays === -1
|
||||||
? -1 // never
|
? -1 // never re-verify
|
||||||
: ttlDays === 0
|
: ttlDays === 0
|
||||||
? 0 // session only (cleared on browser restart by the caller)
|
? 0 // re-verify each session
|
||||||
: Date.now() + ttlDays * 86400000;
|
: Date.now() + ttlDays * 86400000;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction('keys', 'readwrite');
|
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.oncomplete = resolve;
|
||||||
tx.onerror = () => reject(tx.error);
|
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.
|
* Returns { key: CryptoKey, salt: string } or null.
|
||||||
*/
|
*/
|
||||||
async getCachedKey() {
|
async getCachedKey() {
|
||||||
@@ -275,22 +283,8 @@ const SilentSendCrypto = {
|
|||||||
const req = tx.objectStore('keys').get('syncKey');
|
const req = tx.objectStore('keys').get('syncKey');
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
const entry = req.result;
|
const entry = req.result;
|
||||||
if (!entry) return resolve(null);
|
if (!entry?.key) return resolve(null);
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
if (entry.expiresAt === -1) {
|
|
||||||
// Never expires
|
|
||||||
resolve({ key: entry.key, salt: entry.salt });
|
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
req.onerror = () => resolve(null);
|
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() {
|
async clearCachedKey() {
|
||||||
try {
|
try {
|
||||||
const db = await this._openCacheDB();
|
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 first setup (per device), the user enters their password once
|
||||||
// On re-auth, we verify the credential — if it passes, we release
|
// to derive the encryption key. A WebAuthn credential is registered
|
||||||
// the cached key. WebAuthn doesn't produce an encryption key;
|
// on that device. From then on, ALL re-verification uses biometrics
|
||||||
// it gates access to the IndexedDB-cached CryptoKey.
|
// — 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
@@ -3,9 +3,18 @@
|
|||||||
*
|
*
|
||||||
* Wraps browser/api.storage.local with typed helpers for mappings,
|
* Wraps browser/api.storage.local with typed helpers for mappings,
|
||||||
* activity log, and settings.
|
* 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 api from './browser-polyfill.js';
|
||||||
|
import SilentSendCrypto from './crypto.js';
|
||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
MAPPINGS: 'ss_mappings',
|
MAPPINGS: 'ss_mappings',
|
||||||
@@ -14,6 +23,13 @@ const KEYS = {
|
|||||||
SETTINGS: 'ss_settings',
|
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 = {
|
const DEFAULT_SETTINGS = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showHighlights: false,
|
showHighlights: false,
|
||||||
@@ -29,15 +45,128 @@ const DEFAULT_SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Storage = {
|
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 ---
|
// --- Mappings ---
|
||||||
|
|
||||||
async getMappings() {
|
async getMappings() {
|
||||||
const result = await api.storage.local.get(KEYS.MAPPINGS);
|
const data = await this._readSecure(KEYS.MAPPINGS);
|
||||||
return result[KEYS.MAPPINGS] || [];
|
return data || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveMappings(mappings) {
|
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) {
|
async addMapping(mapping) {
|
||||||
@@ -90,14 +219,13 @@ const Storage = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getProfiles() {
|
async getProfiles() {
|
||||||
const result = await api.storage.local.get(KEYS.IDENTITY);
|
const data = await this._readSecure(KEYS.IDENTITY);
|
||||||
const data = result[KEYS.IDENTITY];
|
|
||||||
if (data?.profiles) return data.profiles;
|
if (data?.profiles) return data.profiles;
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveProfiles(profiles) {
|
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) {
|
async addProfile(name) {
|
||||||
@@ -174,8 +302,8 @@ const Storage = {
|
|||||||
// --- Activity Log ---
|
// --- Activity Log ---
|
||||||
|
|
||||||
async getLog() {
|
async getLog() {
|
||||||
const result = await api.storage.local.get(KEYS.LOG);
|
const data = await this._readSecure(KEYS.LOG);
|
||||||
return result[KEYS.LOG] || [];
|
return data || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
async addLogEntry(entry) {
|
async addLogEntry(entry) {
|
||||||
@@ -193,26 +321,30 @@ const Storage = {
|
|||||||
log.length = settings.maxLogEntries;
|
log.length = settings.maxLogEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.storage.local.set({ [KEYS.LOG]: log });
|
await this._writeSecure(KEYS.LOG, log);
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearLog() {
|
async clearLog() {
|
||||||
await api.storage.local.set({ [KEYS.LOG]: [] });
|
await this._writeSecure(KEYS.LOG, []);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Settings ---
|
// --- 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() {
|
async getSettings() {
|
||||||
const result = await api.storage.local.get(KEYS.SETTINGS);
|
const data = await this._readSecure(KEYS.SETTINGS);
|
||||||
return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) };
|
return { ...DEFAULT_SETTINGS, ...(data || {}) };
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveSettings(settings) {
|
async saveSettings(settings) {
|
||||||
const current = await this.getSettings();
|
const current = await this.getSettings();
|
||||||
await api.storage.local.set({
|
await this._writeSecure(
|
||||||
[KEYS.SETTINGS]: { ...current, ...settings },
|
KEYS.SETTINGS,
|
||||||
ss_lastModified: Date.now(),
|
{ ...current, ...settings },
|
||||||
});
|
{ ss_lastModified: Date.now() },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+250
-43
@@ -43,40 +43,88 @@ const SilentSendSync = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async _saveSyncEncryption(config) {
|
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.
|
* Get the decrypted TOTP secret (if configured and key is available).
|
||||||
* Returns { key, salt } or null if auth is required.
|
*/
|
||||||
|
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() {
|
async _getEncryptionKey() {
|
||||||
const config = await this._getSyncEncryption();
|
const config = await this._getSyncEncryption();
|
||||||
if (!config?.enabled) return null;
|
if (!config?.enabled) return null;
|
||||||
|
|
||||||
// Try cached key first
|
|
||||||
const cached = await SilentSendCrypto.getCachedKey();
|
const cached = await SilentSendCrypto.getCachedKey();
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
// Try WebAuthn re-auth if configured
|
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()) {
|
if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
||||||
const hasCredential = await SilentSendCrypto.hasWebAuthnCredential();
|
const hasCredential = await SilentSendCrypto.hasWebAuthnCredential();
|
||||||
if (hasCredential) {
|
if (hasCredential) {
|
||||||
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
||||||
if (verified) {
|
if (verified) {
|
||||||
// WebAuthn passed — but we need the actual key.
|
// Biometric passed — reset the TTL timer and return the key
|
||||||
// The key must have been cached previously (before expiry triggered re-auth).
|
await SilentSendCrypto.markVerified(config.ttlDays ?? 90);
|
||||||
// If it's gone from cache, we need the password again.
|
return cached;
|
||||||
// Check if we stored a wrapped version for WebAuthn recovery.
|
|
||||||
const wrapped = await this._getWrappedKey();
|
|
||||||
if (wrapped) return wrapped;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cached key, no WebAuthn recovery — password needed
|
// 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 at all — password needed (first time on this device)
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -119,7 +167,9 @@ const SilentSendSync = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate with password (+ optional TOTP) and cache the key.
|
* 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} password
|
||||||
* @param {string} [totpCode] — required if TOTP is configured
|
* @param {string} [totpCode] — required if TOTP is configured
|
||||||
@@ -129,17 +179,10 @@ const SilentSendSync = {
|
|||||||
const config = await this._getSyncEncryption();
|
const config = await this._getSyncEncryption();
|
||||||
if (!config?.enabled) return { success: true };
|
if (!config?.enabled) return { success: true };
|
||||||
|
|
||||||
// Validate TOTP if configured
|
// Derive key from password first (needed to decrypt TOTP secret)
|
||||||
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
|
|
||||||
const { key, salt } = await SilentSendCrypto.deriveAndReturnKey(password, config.salt);
|
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) {
|
if (config.verificationBlob) {
|
||||||
try {
|
try {
|
||||||
await SilentSendCrypto.decryptWithKey(config.verificationBlob, key);
|
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;
|
const ttlDays = config.ttlDays ?? 90;
|
||||||
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
|
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
|
||||||
|
|
||||||
// Store wrapped key for WebAuthn recovery
|
// Store key for WebAuthn-gated access
|
||||||
if (config.webauthn) {
|
|
||||||
await this._storeWrappedKey(key, salt);
|
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 };
|
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.
|
* Set up sync encryption for the first time.
|
||||||
* @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }}
|
* @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }}
|
||||||
@@ -187,19 +329,20 @@ const SilentSendSync = {
|
|||||||
webauthn: enableWebAuthn,
|
webauthn: enableWebAuthn,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cache the key immediately (needed before _saveSyncEncryption
|
||||||
|
// can encrypt the TOTP secret)
|
||||||
|
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
|
||||||
|
|
||||||
let totpSecret, totpURI;
|
let totpSecret, totpURI;
|
||||||
if (enableTOTP) {
|
if (enableTOTP) {
|
||||||
totpSecret = SilentSendCrypto.generateTOTPSecret();
|
totpSecret = SilentSendCrypto.generateTOTPSecret();
|
||||||
totpURI = SilentSendCrypto.totpURI(totpSecret);
|
totpURI = SilentSendCrypto.totpURI(totpSecret);
|
||||||
config.totpSecret = totpSecret;
|
config.totpSecret = totpSecret; // _saveSyncEncryption will encrypt this
|
||||||
if (authMethod === 'password') config.authMethod = 'both';
|
if (authMethod === 'password') config.authMethod = 'both';
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._saveSyncEncryption(config);
|
await this._saveSyncEncryption(config);
|
||||||
|
|
||||||
// Cache the key immediately
|
|
||||||
await SilentSendCrypto.cacheKey(key, salt, ttlDays);
|
|
||||||
|
|
||||||
// Set up WebAuthn if requested
|
// Set up WebAuthn if requested
|
||||||
if (enableWebAuthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
if (enableWebAuthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
||||||
try {
|
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 };
|
return { success: true, totpSecret, totpURI };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -219,6 +366,10 @@ const SilentSendSync = {
|
|||||||
* Disable sync encryption entirely.
|
* Disable sync encryption entirely.
|
||||||
*/
|
*/
|
||||||
async disableEncryption() {
|
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 api.storage.local.remove('ss_sync_encryption');
|
||||||
await SilentSendCrypto.clearCachedKey();
|
await SilentSendCrypto.clearCachedKey();
|
||||||
await SilentSendCrypto.clearWebAuthnCredential();
|
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() {
|
async needsAuth() {
|
||||||
const config = await this._getSyncEncryption();
|
const config = await this._getSyncEncryption();
|
||||||
if (!config?.enabled) return false;
|
if (!config?.enabled) return false;
|
||||||
|
|
||||||
const cached = await SilentSendCrypto.getCachedKey();
|
const cached = await SilentSendCrypto.getCachedKey();
|
||||||
if (cached) return false;
|
if (cached) return false; // key exists — WebAuthn handles re-verify
|
||||||
|
|
||||||
// Try WebAuthn silently
|
|
||||||
if (config.webauthn) {
|
|
||||||
const wrapped = await this._getWrappedKey();
|
|
||||||
if (wrapped) return false; // WebAuthn can recover
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt data for sync if encryption is enabled.
|
* 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) {
|
async _encryptForSync(data) {
|
||||||
const config = await this._getSyncEncryption();
|
const config = await this._getSyncEncryption();
|
||||||
@@ -261,17 +419,33 @@ const SilentSendSync = {
|
|||||||
|
|
||||||
const keyInfo = await this._getEncryptionKey();
|
const keyInfo = await this._getEncryptionKey();
|
||||||
if (!keyInfo) {
|
if (!keyInfo) {
|
||||||
// Key not available — caller should prompt for auth
|
|
||||||
return { data: null, encrypted: false, needsAuth: true };
|
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 {
|
return {
|
||||||
data: {
|
data: {
|
||||||
_ssEncrypted: true,
|
_ssEncrypted: true,
|
||||||
payload: encryptedPayload,
|
payload: encryptedPayload,
|
||||||
version: data.version,
|
version: data.version,
|
||||||
lastModified: data.lastModified,
|
lastModified: data.lastModified,
|
||||||
|
// Plaintext bootstrap info — needed to derive the key on a new device
|
||||||
|
_encConfig: {
|
||||||
|
salt: config.salt,
|
||||||
|
verificationBlob: config.verificationBlob,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
encrypted: true,
|
encrypted: true,
|
||||||
};
|
};
|
||||||
@@ -279,17 +453,50 @@ const SilentSendSync = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt sync data if it's encrypted.
|
* 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) {
|
async _decryptFromSync(data) {
|
||||||
if (!data?._ssEncrypted) return { data, decrypted: false };
|
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();
|
const keyInfo = await this._getEncryptionKey();
|
||||||
if (!keyInfo) {
|
if (!keyInfo) {
|
||||||
return { data: null, decrypted: false, needsAuth: true };
|
return { data: null, decrypted: false, needsAuth: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const decrypted = await SilentSendCrypto.decryptWithKey(data.payload, keyInfo.key);
|
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 };
|
return { data: decrypted, decrypted: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -160,16 +160,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
|
||||||
<input type="password" id="syncAuthPassword" placeholder="Password" autocomplete="current-password"
|
<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">
|
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">
|
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-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>
|
||||||
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
|
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+96
-28
@@ -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 () => {
|
$('#btnSyncAuth').addEventListener('click', async () => {
|
||||||
const password = $('#syncAuthPassword').value;
|
const password = $('#syncAuthPassword').value;
|
||||||
const totpCode = $('#syncAuthTOTP').value;
|
const totpCode = $('#syncAuthTOTPForPassword').value;
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setSyncAuthStatus('Enter your password.', 'warn');
|
setSyncAuthStatus('Enter your password.', 'warn');
|
||||||
return;
|
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) {
|
if (result.success) {
|
||||||
$('#syncAuthPrompt').style.display = 'none';
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
$('#syncAuthPassword').value = '';
|
$('#syncAuthPassword').value = '';
|
||||||
$('#syncAuthTOTP').value = '';
|
$('#syncAuthTOTPForPassword').value = '';
|
||||||
setSyncEncStatus('Authenticated. Sync data unlocked.', 'ok');
|
setSyncEncStatus(cached ? 'Re-verified with password.' : 'Authenticated. Sync data unlocked.', 'ok');
|
||||||
} else {
|
} else {
|
||||||
setSyncAuthStatus(result.reason, 'error');
|
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 () => {
|
$('#btnSyncAuthBiometric').addEventListener('click', async () => {
|
||||||
setSyncAuthStatus('Waiting for biometric...', 'neutral');
|
setSyncAuthStatus('Waiting for biometric...', 'neutral');
|
||||||
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
||||||
if (verified) {
|
if (verified) {
|
||||||
// Try to recover wrapped key
|
|
||||||
const wrapped = await SilentSendSync._getWrappedKey();
|
|
||||||
if (wrapped) {
|
|
||||||
const config = await SilentSendSync._getSyncEncryption();
|
const config = await SilentSendSync._getSyncEncryption();
|
||||||
const ttlDays = config?.ttlDays ?? 90;
|
const ttlDays = config?.ttlDays ?? 90;
|
||||||
await SilentSendCrypto.cacheKey(wrapped.key, wrapped.salt, ttlDays);
|
await SilentSendCrypto.markVerified(ttlDays);
|
||||||
$('#syncAuthPrompt').style.display = 'none';
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
setSyncEncStatus('Authenticated via biometric. Sync data unlocked.', 'ok');
|
setSyncEncStatus('Re-verified via biometric.', 'ok');
|
||||||
} else {
|
} else {
|
||||||
setSyncAuthStatus('Biometric verified but key not found. Enter password.', 'warn');
|
setSyncAuthStatus('Biometric failed. Try TOTP or password.', 'error');
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSyncAuthStatus('Biometric verification failed.', 'error');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -923,20 +945,26 @@ async function showEncryptionConfigured() {
|
|||||||
else if (config.authMethod === 'totp') parts.push('TOTP');
|
else if (config.authMethod === 'totp') parts.push('TOTP');
|
||||||
else parts.push('Password');
|
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'
|
const ttl = config.ttlDays === -1 ? 'never re-verify'
|
||||||
: config.ttlDays === 0 ? 'each session'
|
: config.ttlDays === 0 ? 're-verify each session'
|
||||||
: `every ${config.ttlDays}d`;
|
: `re-verify every ${config.ttlDays}d`;
|
||||||
parts.push(ttl);
|
parts.push(ttl);
|
||||||
|
|
||||||
$('#encryptionInfo').textContent = `(${parts.join(' · ')})`;
|
$('#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();
|
const needsAuth = await SilentSendSync.needsAuth();
|
||||||
if (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,24 +975,64 @@ function showEncryptionNotConfigured() {
|
|||||||
$('#totpSetupResult').style.display = 'none';
|
$('#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();
|
const config = await SilentSendSync._getSyncEncryption();
|
||||||
$('#syncAuthPrompt').style.display = 'block';
|
const promptEl = $('#syncAuthPrompt');
|
||||||
|
promptEl.style.display = 'block';
|
||||||
|
|
||||||
// Show TOTP field if needed
|
const isReverify = (mode === 'reverify') ||
|
||||||
if (config?.totpSecret) {
|
(mode === 'decrypt' && await SilentSendCrypto.getCachedKey());
|
||||||
$('#syncAuthTOTP').style.display = '';
|
|
||||||
|
// 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 {
|
} else {
|
||||||
$('#syncAuthTOTP').style.display = 'none';
|
headerEl.textContent = 'First time on this device — enter your sync encryption password';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show biometric button if available
|
// 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()) {
|
if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
||||||
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
|
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
|
||||||
$('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none';
|
$('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none';
|
||||||
} else {
|
} else {
|
||||||
$('#btnSyncAuthBiometric').style.display = 'none';
|
$('#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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSyncEncStatus(msg, type) {
|
function setSyncEncStatus(msg, type) {
|
||||||
|
|||||||
+23
-3
@@ -35,6 +35,27 @@
|
|||||||
<button class="tab" data-tab="test">Test</button>
|
<button class="tab" data-tab="test">Test</button>
|
||||||
</nav>
|
</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 -->
|
<!-- First-Run Setup Banner -->
|
||||||
<div class="first-run-banner" id="firstRunBanner" style="display:none">
|
<div class="first-run-banner" id="firstRunBanner" style="display:none">
|
||||||
<div class="first-run-icon">!</div>
|
<div class="first-run-icon">!</div>
|
||||||
@@ -175,9 +196,8 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="privacy-note">
|
<div class="privacy-note">
|
||||||
Your data never leaves your browser. No servers, no tracking, no analytics.
|
Your data never leaves your browser. No servers, no tracking, no analytics.
|
||||||
Identity data is stored unencrypted in local browser storage — anyone with
|
<span id="privacyEncNote">When sync encryption is enabled, identity data is
|
||||||
access to your computer could read it. This is the same as browser cookies
|
AES-256 encrypted at rest — unreadable without your password.</span>
|
||||||
and localStorage, not as secure as saved passwords (which are OS-encrypted).
|
|
||||||
</div>
|
</div>
|
||||||
<div class="privacy-note" style="color:#b45309;background:#fef3c7;padding:6px 8px;border-radius:4px;margin-bottom:6px">
|
<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
|
Silent Send is a convenience tool, not a security guarantee. It can miss
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SmartPatterns from '../lib/smart-patterns.js';
|
|||||||
import SecretScanner from '../lib/secret-scanner.js';
|
import SecretScanner from '../lib/secret-scanner.js';
|
||||||
import AutoDetect from '../lib/auto-detect.js';
|
import AutoDetect from '../lib/auto-detect.js';
|
||||||
import Storage from '../lib/storage.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';
|
import api from '../lib/browser-polyfill.js';
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
@@ -18,6 +20,20 @@ const $$ = (sel) => document.querySelectorAll(sel);
|
|||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
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();
|
mappings = await Storage.getMappings();
|
||||||
profiles = await Storage.getProfiles();
|
profiles = await Storage.getProfiles();
|
||||||
identity = await Storage.getIdentity();
|
identity = await Storage.getIdentity();
|
||||||
@@ -191,8 +207,98 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
api.runtime.openOptionsPage();
|
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 ---
|
// --- Profiles ---
|
||||||
function renderProfileSelector() {
|
function renderProfileSelector() {
|
||||||
const select = $('#profileSelect');
|
const select = $('#profileSelect');
|
||||||
|
|||||||
Reference in New Issue
Block a user