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()
+ '