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:
+73
-2
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user