From 59566f88e30113154ae1edc42115d1cde023f218 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 27 Feb 2026 22:39:27 +0800 Subject: [PATCH] feat: implement vault locking mechanism with auto-lock settings and unlock functionality --- public/web/crypto.js | 23 ++++- public/web/main.js | 191 ++++++++++++++++++++++++++++++++++++++++-- public/web/styles.css | 1 - 3 files changed, 202 insertions(+), 13 deletions(-) diff --git a/public/web/crypto.js b/public/web/crypto.js index 2f4bf75..88130e7 100644 --- a/public/web/crypto.js +++ b/public/web/crypto.js @@ -27,9 +27,25 @@ export async function pbkdf2(passwordOrBytes, saltOrBytes, iterations, keyLen) { } export async function hkdfExpand(prk, info, length) { - var key = await crypto.subtle.importKey('raw', prk, 'HKDF', false, ['deriveBits']); - var bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(info) }, key, length * 8); - return new Uint8Array(bits); + var enc = new TextEncoder(); + var key = await crypto.subtle.importKey('raw', prk, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + var infoBytes = enc.encode(info || ''); + var result = new Uint8Array(length); + var prev = new Uint8Array(0); + var off = 0; + var cnt = 1; + while (off < length) { + var inp = new Uint8Array(prev.length + infoBytes.length + 1); + inp.set(prev, 0); + inp.set(infoBytes, prev.length); + inp[inp.length - 1] = cnt & 0xff; + prev = new Uint8Array(await crypto.subtle.sign('HMAC', key, inp)); + var c = Math.min(prev.length, length - off); + result.set(prev.slice(0, c), off); + off += c; + cnt += 1; + } + return result; } export async function hmacSha256(keyBytes, dataBytes) { @@ -132,4 +148,3 @@ export async function calcTotpNow(rawSecret) { var code = (bin % 1000000).toString().padStart(6, '0'); return { code: code, remain: remain }; } - diff --git a/public/web/main.js b/public/web/main.js index 6d8d5b1..47dc6fe 100644 --- a/public/web/main.js +++ b/public/web/main.js @@ -48,7 +48,13 @@ export function startNodewardenApp(runtimeConfig) { totpSetupToken: '', totpDisableOpen: false, totpDisablePassword: '', - totpDisableError: '' + totpDisableError: '', + unlockPassword: '', + unlockError: '', + lockTimeoutMinutes: 15, + lockLastActiveTs: Date.now(), + lockCheckTimer: 0, + lockChannel: null }; var NO_FOLDER_FILTER = '__none__'; var i18n = I18N; @@ -59,11 +65,40 @@ export function startNodewardenApp(runtimeConfig) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function sessionKey() { return 'nodewarden.web.session.v2'; } + function lockSettingsKey() { return 'nodewarden.web.lock.v1'; } function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } function clearMsg() { state.msg = ''; } function renderMsg() { return state.msg ? '
' + esc(state.msg) + '
' : ''; } - function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } - function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } + function saveSession() { + if (!state.session) { localStorage.removeItem(sessionKey()); return; } + var persisted = { + accessToken: state.session.accessToken || '', + refreshToken: state.session.refreshToken || '', + email: state.session.email || '' + }; + localStorage.setItem(sessionKey(), JSON.stringify(persisted)); + } + function loadSession() { + try { + var r = localStorage.getItem(sessionKey()); + if (!r) return null; + var p = JSON.parse(r); + if (!p || !p.accessToken || !p.refreshToken) return null; + return { accessToken: p.accessToken, refreshToken: p.refreshToken, email: p.email || '' }; + } catch (e) { return null; } + } + function saveLockSettings() { + localStorage.setItem(lockSettingsKey(), JSON.stringify({ lockTimeoutMinutes: Number(state.lockTimeoutMinutes) || 0 })); + } + function loadLockSettings() { + try { + var r = localStorage.getItem(lockSettingsKey()); + if (!r) return; + var p = JSON.parse(r); + var mins = Number(p && p.lockTimeoutMinutes); + if (Number.isFinite(mins) && mins >= 0) state.lockTimeoutMinutes = mins; + } catch (_) {} + } async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } } async function decryptVault(){ if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return; @@ -112,8 +147,101 @@ export function startNodewardenApp(runtimeConfig) { return { hash: bytesToBase64(h), masterKey: mk, kdfIterations: it }; } + function clearVaultMemory() { + state.ciphers = []; + state.folders = []; + state.folderFilterId = ''; + state.selectedCipherId = ''; + state.selectedMap = {}; + state.detailMode = 'view'; + state.detailDraft = null; + state.showSelectedPassword = false; + } + + function markUserActivity() { + if (state.phase === 'app') state.lockLastActiveTs = Date.now(); + } + + function ensureLockChannel() { + if (state.lockChannel || typeof BroadcastChannel === 'undefined') return; + try { + state.lockChannel = new BroadcastChannel('nodewarden-lock-v1'); + state.lockChannel.onmessage = function (ev) { + var msg = ev && ev.data; + if (!msg || msg.type !== 'lock') return; + if (state.phase === 'app') lockVault(false, false); + }; + } catch (_) {} + } + + function ensureAutoLockTicker() { + if (state.lockCheckTimer) return; + state.lockCheckTimer = setInterval(function () { + if (state.phase !== 'app') return; + var mins = Number(state.lockTimeoutMinutes) || 0; + if (mins <= 0) return; + if ((Date.now() - state.lockLastActiveTs) >= mins * 60 * 1000) { + lockVault(true, true); + } + }, 5000); + } + + function lockVault(showMsg, broadcast) { + if (state.session) { + delete state.session.symEncKey; + delete state.session.symMacKey; + } + clearVaultMemory(); + state.pendingLogin = null; + state.loginTotpToken = ''; + state.loginTotpError = ''; + state.unlockPassword = ''; + state.unlockError = ''; + state.phase = 'locked'; + if (broadcast !== false && state.lockChannel) { + try { state.lockChannel.postMessage({ type: 'lock', at: Date.now() }); } catch (_) {} + } + if (showMsg) setMsg('Vault locked.', 'ok'); + else render(); + } + + async function onUnlock(form) { + clearMsg(); + state.unlockError = ''; + var fd = new FormData(form); + state.unlockPassword = String(fd.get('password') || ''); + if (!state.unlockPassword) { + state.unlockError = 'Please input master password.'; + render(); + return; + } + try { + var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '').toLowerCase(); + if (!email) throw new Error('email missing'); + var d = await deriveLoginHash(email, state.unlockPassword); + var ek = await hkdfExpand(d.masterKey, 'enc', 32); + var em = await hkdfExpand(d.masterKey, 'mac', 32); + var symKeyBytes = await decryptBw(state.profile.key, ek, em); + if (!symKeyBytes || symKeyBytes.length < 64) throw new Error('invalid key'); + state.session.symEncKey = bytesToBase64(symKeyBytes.slice(0, 32)); + state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64)); + state.unlockPassword = ''; + state.unlockError = ''; + await loadVault(); + await loadAdminData(); + state.phase = 'app'; + state.tab = 'vault'; + state.lockLastActiveTs = Date.now(); + render(); + setMsg('Unlocked.', 'ok'); + } catch (e) { + state.unlockError = 'Unlock failed. Master password is incorrect.'; + render(); + } + } + function logout(){ - state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.phase='login'; saveSession(); clearMsg(); render(); + state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.phase='login'; saveSession(); clearMsg(); render(); } async function authFetch(path, options){ @@ -179,7 +307,7 @@ export function startNodewardenApp(runtimeConfig) { try{ var x=await calcTotpNow(raw); if(!x){ vEl.textContent='N/A'; rEl.textContent=''; return; } - vEl.textContent=x.token; + vEl.textContent=x.code; rEl.textContent=t('totpLiveIn')+': '+x.remain+'s'; }catch(e){ vEl.textContent='N/A'; @@ -761,6 +889,30 @@ export function startNodewardenApp(runtimeConfig) { + ''; } + function renderLockedScreen(){ + var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : ''); + return '' + + '
' + + '
'+t('langSwitch')+'
' + + '
' + + '
' + + ' ' + + '
Unlock Vault
' + + '
'+esc(email)+'
' + + '
' + + renderMsg() + + (state.unlockError?('
'+esc(state.unlockError)+'
'):'') + + '
' + + '
' + + ' ' + + '
' + + '
' + + ' ' + + '
' + + '
' + + '
'; + } + function renderVaultTab(){ var list=filteredCiphers(); function renderFolderOptions(selectedId){ @@ -852,9 +1004,11 @@ export function startNodewardenApp(runtimeConfig) { function renderSettingsTab(){ var p=state.profile||{}; var secret=currentTotpSecret(); + var lockMins = Number(state.lockTimeoutMinutes)||0; return '' + renderMsg() + '

'+t('settings')+'

' + + '

Vault Lock

' + '

'+t('profile')+'

' + '

'+t('changePwd')+'

After success, current sessions are revoked and you must log in again.
' + '

'+t('totpSetup')+'

QR loading...
Use secret key below
Disable action prompts for master password.
'; @@ -936,6 +1090,7 @@ export function startNodewardenApp(runtimeConfig) { + ' ' + '' @@ -953,6 +1108,7 @@ export function startNodewardenApp(runtimeConfig) { if(state.phase==='loading'){ app.innerHTML='
'+t('loading')+'
'; return; } if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } + if(state.phase==='locked'){ app.innerHTML=renderLockedScreen(); return; } app.innerHTML=renderApp(); updateLiveTotpDisplay(); renderTotpSetupQr(); @@ -960,10 +1116,13 @@ export function startNodewardenApp(runtimeConfig) { async function init(){ var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); + loadLockSettings(); ensureTotpTicker(); + ensureLockChannel(); + ensureAutoLockTicker(); var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); if(state.session){ - try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } + try{ await loadProfile(); state.phase='locked'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } } state.phase=registered?'login':'register'; render(); } @@ -1019,11 +1178,20 @@ export function startNodewardenApp(runtimeConfig) { try{ var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); var symKeyBytes=await decryptBw(state.profile.key,ek,em); - if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); } + if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); } }catch(e){ console.warn('Key derivation failed:',e); } - await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; + await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; state.lockLastActiveTs=Date.now(); setMsg('Login success.', 'ok'); } + async function onSaveLockSettings(form){ + var fd=new FormData(form); + var mins=Number(fd.get('lockTimeoutMinutes')||0); + if(!Number.isFinite(mins)||mins<0) mins=15; + state.lockTimeoutMinutes=mins; + saveLockSettings(); + state.lockLastActiveTs=Date.now(); + setMsg('Lock settings saved.', 'ok'); + } async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } async function onChangePassword(form){ var fd=new FormData(form); @@ -1090,6 +1258,8 @@ export function startNodewardenApp(runtimeConfig) { if(form.id==='registerForm') return void onRegister(form); if(form.id==='loginForm') return void onLoginPassword(form); if(form.id==='loginTotpForm') return void onLoginTotp(form); + if(form.id==='unlockForm') return void onUnlock(form); + if(form.id==='lockForm') return void onSaveLockSettings(form); if(form.id==='profileForm') return void onSaveProfile(form); if(form.id==='passwordForm') return void onChangePassword(form); if(form.id==='totpEnableForm') return void onEnableTotp(form); @@ -1103,6 +1273,7 @@ export function startNodewardenApp(runtimeConfig) { if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; } if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; } if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; } + if(a==='lock'){ lockVault(true, true); return; } if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; } if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; } if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } @@ -1198,5 +1369,9 @@ export function startNodewardenApp(runtimeConfig) { } }); + ['click','keydown','mousemove','touchstart','scroll'].forEach(function(evt){ + window.addEventListener(evt, markUserActivity, { passive: true }); + }); + init(); } diff --git a/public/web/styles.css b/public/web/styles.css index 7f4845f..b22833b 100644 --- a/public/web/styles.css +++ b/public/web/styles.css @@ -367,7 +367,6 @@ border-radius: 5px; object-fit: contain; flex-shrink: 0; - border: 1px solid #d9dfe8; background: #fff; } .vault-item-icon-wrap { width:24px; height:24px; position:relative; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; }