feat: TOTP as standalone re-auth method alongside WebAuthn/password

Re-verification (TTL expired, key still cached) now accepts any ONE of:
- WebAuthn (biometric/PIN)
- TOTP code alone (no password needed)
- Password alone (no TOTP needed)

First-device setup still requires password (+ TOTP if configured) since
the password is needed to derive the encryption key.

Added reverifyWithTOTP() and reverifyWithPassword() to sync.js.
Auth prompt UI adapts: first-device shows password+TOTP fields,
re-verify shows all three methods as alternatives.

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 18:42:05 +00:00
parent 7fd70e0891
commit 51a25a208b
3 changed files with 165 additions and 26 deletions
+73 -2
View File
@@ -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 }}
+14 -3
View File
@@ -160,16 +160,27 @@
</div>
</div>
<!-- Auth prompt (shown when key cache expired and sync needs decryption) -->
<!-- Auth prompt — first-device setup (password + optional TOTP) -->
<div id="syncAuthPrompt" style="display:none;margin-top:10px;padding:10px;background:#fffbeb;border:1px solid #fcd34d;border-radius:6px">
<p style="font-size:12px;font-weight:500;margin:0 0 8px;color:#92400e">Authentication required</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<input type="password" id="syncAuthPassword" placeholder="Password" autocomplete="current-password"
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<input type="text" id="syncAuthTOTP" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
<input type="text" id="syncAuthTOTPForPassword" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
style="width:80px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;display:none">
<button class="btn btn-primary btn-sm" id="btnSyncAuth">Unlock</button>
<button class="btn btn-sm" id="btnSyncAuthBiometric" style="display:none">Biometric</button>
</div>
<!-- Re-verify alternatives (shown when key exists but TTL expired) -->
<div id="reverifyOptions" style="display:none;margin-top:8px;padding-top:8px;border-top:1px solid #fde68a">
<p style="font-size:11px;color:#92400e;margin:0 0 6px">Or re-verify with any one of these:</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<button class="btn btn-sm" id="btnSyncAuthBiometric" style="display:none">Biometric / PIN</button>
<div id="totpReverifyGroup" style="display:none;display:flex;gap:4px;align-items:end">
<input type="text" id="syncAuthTOTP" placeholder="TOTP code" autocomplete="one-time-code" inputmode="numeric" maxlength="6"
style="width:80px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-sm" id="btnSyncAuthTOTP">Verify</button>
</div>
</div>
</div>
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
</div>
+78 -21
View File
@@ -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';
}
}