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.
|
* Authenticate with password (+ optional TOTP) and cache the key.
|
||||||
* Called from the UI — typically only needed ONCE per device.
|
* Called from the UI — typically only needed ONCE per device.
|
||||||
* After this, the key persists in IndexedDB and WebAuthn handles
|
* After this, the key persists in IndexedDB and re-verification
|
||||||
* any re-verification.
|
* can use WebAuthn or TOTP alone.
|
||||||
*
|
*
|
||||||
* @param {string} password
|
* @param {string} password
|
||||||
* @param {string} [totpCode] — required if TOTP is configured
|
* @param {string} [totpCode] — required if TOTP is configured
|
||||||
@@ -189,6 +189,77 @@ const SilentSendSync = {
|
|||||||
return { success: true };
|
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.
|
* Set up sync encryption for the first time.
|
||||||
* @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }}
|
* @param {{ password: string, enableTOTP?: boolean, authMethod?: string, ttlDays?: number, enableWebAuthn?: boolean }}
|
||||||
|
|||||||
@@ -160,16 +160,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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">
|
||||||
<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">
|
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-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>
|
||||||
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
|
<div id="syncAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
|
||||||
</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 () => {
|
$('#btnSyncAuth').addEventListener('click', async () => {
|
||||||
const password = $('#syncAuthPassword').value;
|
const password = $('#syncAuthPassword').value;
|
||||||
const totpCode = $('#syncAuthTOTP').value;
|
const totpCode = $('#syncAuthTOTPForPassword').value;
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setSyncAuthStatus('Enter your password.', 'warn');
|
setSyncAuthStatus('Enter your password.', 'warn');
|
||||||
return;
|
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) {
|
if (result.success) {
|
||||||
$('#syncAuthPrompt').style.display = 'none';
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
$('#syncAuthPassword').value = '';
|
$('#syncAuthPassword').value = '';
|
||||||
$('#syncAuthTOTP').value = '';
|
$('#syncAuthTOTPForPassword').value = '';
|
||||||
setSyncEncStatus('Authenticated. Sync data unlocked.', 'ok');
|
setSyncEncStatus(cached ? 'Re-verified with password.' : 'Authenticated. Sync data unlocked.', 'ok');
|
||||||
} else {
|
} else {
|
||||||
setSyncAuthStatus(result.reason, 'error');
|
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 () => {
|
$('#btnSyncAuthBiometric').addEventListener('click', async () => {
|
||||||
setSyncAuthStatus('Waiting for biometric...', 'neutral');
|
setSyncAuthStatus('Waiting for biometric...', 'neutral');
|
||||||
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
const verified = await SilentSendCrypto.webAuthnAuthenticate();
|
||||||
@@ -899,9 +927,9 @@ async function initSyncEncryptionUI() {
|
|||||||
const ttlDays = config?.ttlDays ?? 90;
|
const ttlDays = config?.ttlDays ?? 90;
|
||||||
await SilentSendCrypto.markVerified(ttlDays);
|
await SilentSendCrypto.markVerified(ttlDays);
|
||||||
$('#syncAuthPrompt').style.display = 'none';
|
$('#syncAuthPrompt').style.display = 'none';
|
||||||
setSyncEncStatus('Verified via biometric.', 'ok');
|
setSyncEncStatus('Re-verified via biometric.', 'ok');
|
||||||
} else {
|
} 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();
|
const needsAuth = await SilentSendSync.needsAuth();
|
||||||
if (needsAuth) {
|
if (needsAuth) {
|
||||||
showSyncAuthPrompt('first-device');
|
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.
|
* Show the auth prompt.
|
||||||
* @param {'first-device'|'reverify'|'decrypt'} mode
|
* @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') {
|
async function showSyncAuthPrompt(mode = 'decrypt') {
|
||||||
const config = await SilentSendSync._getSyncEncryption();
|
const config = await SilentSendSync._getSyncEncryption();
|
||||||
const promptEl = $('#syncAuthPrompt');
|
const promptEl = $('#syncAuthPrompt');
|
||||||
promptEl.style.display = 'block';
|
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');
|
const headerEl = promptEl.querySelector('p');
|
||||||
if (mode === 'first-device') {
|
if (mode === 'first-device') {
|
||||||
headerEl.textContent = 'First time on this device — enter your sync encryption password';
|
headerEl.textContent = 'First time on this device — enter your sync encryption password';
|
||||||
} else if (mode === 'reverify') {
|
} else if (isReverify) {
|
||||||
headerEl.textContent = 'Re-verification required — use biometric or enter password';
|
headerEl.textContent = 'Re-verification required — use any method below';
|
||||||
} else {
|
} 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
|
// First-device: show TOTP alongside password if configured
|
||||||
if (config?.totpSecret) {
|
if (!isReverify && config?.totpSecret) {
|
||||||
$('#syncAuthTOTP').style.display = '';
|
$('#syncAuthTOTPForPassword').style.display = '';
|
||||||
} else {
|
} else {
|
||||||
$('#syncAuthTOTP').style.display = 'none';
|
$('#syncAuthTOTPForPassword').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show biometric button — available unless this is first-device setup
|
// Re-verify alternatives section
|
||||||
// (no WebAuthn credential exists yet on a new device)
|
const reverifyOpts = $('#reverifyOptions');
|
||||||
if (mode !== 'first-device' && config?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
|
if (isReverify) {
|
||||||
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
|
reverifyOpts.style.display = 'block';
|
||||||
$('#btnSyncAuthBiometric').style.display = hasCred ? '' : 'none';
|
|
||||||
|
// 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 {
|
} else {
|
||||||
|
reverifyOpts.style.display = 'none';
|
||||||
$('#btnSyncAuthBiometric').style.display = 'none';
|
$('#btnSyncAuthBiometric').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user