fix: WebAuthn as primary re-auth, key persists indefinitely
- CryptoKey now persists in IndexedDB forever — never auto-deleted - TTL controls when re-verification is needed, not key lifetime - WebAuthn is the primary re-auth method (not a post-expiry fallback) - Password only needed once per device (first-time setup) - Added needsReverification() and markVerified() to crypto.js - Auth prompt adapts message: first-device vs re-verify vs decrypt - Biometric button hidden on first-device setup (no credential yet) https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
+78
-28
@@ -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);
|
||||||
|
resolve({ key: entry.key, salt: entry.salt });
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
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.
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+58
-31
@@ -47,36 +47,55 @@ const SilentSendSync = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtain the encryption key — from cache, WebAuthn, or requires password.
|
* Obtain the encryption key for sync operations.
|
||||||
* Returns { key, salt } or null if auth is required.
|
|
||||||
*
|
*
|
||||||
* 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) {
|
||||||
if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
// Key exists — check if re-verification is needed
|
||||||
const hasCredential = await SilentSendCrypto.hasWebAuthnCredential();
|
const needsReverify = await SilentSendCrypto.needsReverification();
|
||||||
if (hasCredential) {
|
|
||||||
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
if (!needsReverify) {
|
||||||
if (verified) {
|
return cached; // Key valid, no re-verification needed
|
||||||
// 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.
|
// TTL expired — try WebAuthn as primary re-auth
|
||||||
// Check if we stored a wrapped version for WebAuthn recovery.
|
if (config.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
||||||
const wrapped = await this._getWrappedKey();
|
const hasCredential = await SilentSendCrypto.hasWebAuthnCredential();
|
||||||
if (wrapped) return wrapped;
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -119,7 +138,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 WebAuthn handles
|
||||||
|
* any re-verification.
|
||||||
*
|
*
|
||||||
* @param {string} password
|
* @param {string} password
|
||||||
* @param {string} [totpCode] — required if TOTP is configured
|
* @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;
|
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 };
|
||||||
@@ -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() {
|
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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
|
|
||||||
<!-- Auth prompt (shown when key cache expired and sync needs decryption) -->
|
<!-- Auth prompt (shown when key cache expired and sync needs decryption) -->
|
||||||
<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">
|
||||||
|
|||||||
+33
-23
@@ -890,24 +890,18 @@ async function initSyncEncryptionUI() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth prompt — Biometric button
|
// Auth prompt — Biometric button (primary re-auth after first password entry)
|
||||||
$('#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 config = await SilentSendSync._getSyncEncryption();
|
||||||
const wrapped = await SilentSendSync._getWrappedKey();
|
const ttlDays = config?.ttlDays ?? 90;
|
||||||
if (wrapped) {
|
await SilentSendCrypto.markVerified(ttlDays);
|
||||||
const config = await SilentSendSync._getSyncEncryption();
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
const ttlDays = config?.ttlDays ?? 90;
|
setSyncEncStatus('Verified via biometric.', 'ok');
|
||||||
await SilentSendCrypto.cacheKey(wrapped.key, wrapped.salt, ttlDays);
|
|
||||||
$('#syncAuthPrompt').style.display = 'none';
|
|
||||||
setSyncEncStatus('Authenticated via biometric. Sync data unlocked.', 'ok');
|
|
||||||
} else {
|
|
||||||
setSyncAuthStatus('Biometric verified but key not found. Enter password.', 'warn');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setSyncAuthStatus('Biometric verification failed.', 'error');
|
setSyncAuthStatus('Biometric failed. Use password instead.', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -923,20 +917,20 @@ 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,9 +941,24 @@ function showEncryptionNotConfigured() {
|
|||||||
$('#totpSetupResult').style.display = 'none';
|
$('#totpSetupResult').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSyncAuthPrompt() {
|
/**
|
||||||
|
* Show the auth prompt.
|
||||||
|
* @param {'first-device'|'reverify'|'decrypt'} mode
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Adjust header message based on context
|
||||||
|
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 {
|
||||||
|
headerEl.textContent = 'Authentication required — encrypted sync data needs decryption';
|
||||||
|
}
|
||||||
|
|
||||||
// Show TOTP field if needed
|
// Show TOTP field if needed
|
||||||
if (config?.totpSecret) {
|
if (config?.totpSecret) {
|
||||||
@@ -958,8 +967,9 @@ async function showSyncAuthPrompt() {
|
|||||||
$('#syncAuthTOTP').style.display = 'none';
|
$('#syncAuthTOTP').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show biometric button if available
|
// Show biometric button — available unless this is first-device setup
|
||||||
if (config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
// (no WebAuthn credential exists yet on a new device)
|
||||||
|
if (mode !== 'first-device' && 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user