diff --git a/src/lib/sync.js b/src/lib/sync.js index 088e446..eb3dd21 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -139,8 +139,8 @@ const SilentSendSync = { /** * Authenticate with password (+ optional TOTP) and cache the key. * Called from the UI — typically only needed ONCE per device. - * After this, the key persists in IndexedDB and WebAuthn handles - * any re-verification. + * 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 @@ -189,6 +189,77 @@ const SilentSendSync = { 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.' }; + if (!config.totpSecret) return { success: false, reason: 'TOTP not configured.' }; + + // Must have a cached key — TOTP can't derive one + const cached = await SilentSendCrypto.getCachedKey(); + if (!cached) return { success: false, reason: 'No cached key. Password required for first setup.' }; + + // Validate the TOTP code + const valid = await SilentSendCrypto.validateTOTP(config.totpSecret, totpCode); + 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 }} diff --git a/src/options/options.html b/src/options/options.html index 9804405..39a0f8d 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -160,16 +160,27 @@ - +
diff --git a/src/options/options.js b/src/options/options.js index e2ae6a0..8cda4a1 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -869,28 +869,56 @@ 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 (primary re-auth after first password entry) + // 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(); @@ -899,9 +927,9 @@ async function initSyncEncryptionUI() { const ttlDays = config?.ttlDays ?? 90; await SilentSendCrypto.markVerified(ttlDays); $('#syncAuthPrompt').style.display = 'none'; - setSyncEncStatus('Verified via biometric.', 'ok'); + setSyncEncStatus('Re-verified via biometric.', 'ok'); } else { - setSyncAuthStatus('Biometric failed. Use password instead.', 'error'); + setSyncAuthStatus('Biometric failed. Try TOTP or password.', 'error'); } }); } @@ -931,6 +959,12 @@ async function showEncryptionConfigured() { const needsAuth = await SilentSendSync.needsAuth(); if (needsAuth) { showSyncAuthPrompt('first-device'); + } else { + // Key exists — check if re-verification is needed + const needsReverify = await SilentSendSync.needsReverification(); + if (needsReverify) { + showSyncAuthPrompt('reverify'); + } } } @@ -944,35 +978,58 @@ function showEncryptionNotConfigured() { /** * 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 promptEl = $('#syncAuthPrompt'); promptEl.style.display = 'block'; - // Adjust header message based on context + 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 (mode === 'reverify') { - headerEl.textContent = 'Re-verification required — use biometric or enter password'; + } else if (isReverify) { + headerEl.textContent = 'Re-verification required — use any method below'; } else { - headerEl.textContent = 'Authentication required — encrypted sync data needs decryption'; + headerEl.textContent = 'First time on this device — enter your sync encryption password'; } - // Show TOTP field if needed - if (config?.totpSecret) { - $('#syncAuthTOTP').style.display = ''; + // First-device: show TOTP alongside password if configured + if (!isReverify && config?.totpSecret) { + $('#syncAuthTOTPForPassword').style.display = ''; } else { - $('#syncAuthTOTP').style.display = 'none'; + $('#syncAuthTOTPForPassword').style.display = 'none'; } - // Show biometric button — available unless this is first-device setup - // (no WebAuthn credential exists yet on a new device) - if (mode !== 'first-device' && config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) { - const hasCred = await SilentSendCrypto.hasWebAuthnCredential(); - $('#btnSyncAuthBiometric').style.display = hasCred ? '' : '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 (config?.totpSecret) { + totpGroup.style.display = 'flex'; + } else { + totpGroup.style.display = 'none'; + } } else { + reverifyOpts.style.display = 'none'; $('#btnSyncAuthBiometric').style.display = 'none'; } }