diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 78a038b..125b905 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -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. // ---------------------------------------------------------------- /** diff --git a/src/lib/sync.js b/src/lib/sync.js index e49489d..088e446 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -47,36 +47,55 @@ const SilentSendSync = { }, /** - * Obtain the encryption key — from cache, WebAuthn, or requires password. - * Returns { key, salt } or null if auth is required. + * 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 +138,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 WebAuthn handles + * any re-verification. * * @param {string} password * @param {string} [totpCode] — required if TOTP is configured @@ -148,13 +169,21 @@ const SilentSendSync = { } } - // Cache the key + // 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 }; @@ -233,20 +262,18 @@ 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; }, diff --git a/src/options/options.html b/src/options/options.html index fcc9ea0..9804405 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -162,7 +162,7 @@