From 4752aca43c240bd34abfa3f4b46b2f3462f867c4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:10:47 -0400 Subject: [PATCH] Passwords: add and edit logins and notes, delete to trash, password generator, text Send Co-Authored-By: Claude Sonnet 5.5 --- public/vault-view.js | 95 ++++++++++++++++++++++++++++++++++++++++++-- pwvault.js | 64 ++++++++++++++++++++++++++--- 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/public/vault-view.js b/public/vault-view.js index 12e1813..d3cb619 100644 --- a/public/vault-view.js +++ b/public/vault-view.js @@ -48,10 +48,13 @@ function vtUnlock(st, need2fa) { async function vtList(st) { const v = $('#view'); - v.innerHTML = `

Passwords

+ v.innerHTML = `

Passwords

Loading…

-

Locks itself after a few idle minutes. Open the full vault to add or change entries.

`; +

Locks itself after a few idle minutes. Open the full vault for cards, identities and the trash.

`; const lockedOut = () => { vtState.items = null; vtUnlock({ ...st, unlocked: false }); }; + $('#vtnew').onclick = () => vtEdit(null, () => load(), lockedOut); + $('#vtgen').onclick = () => vtGenerate(); + $('#vtsend').onclick = () => vtSend(lockedOut); $('#vtlock').onclick = async () => { try { await vtReq('/lock', {}); } catch {} lockedOut(); toast('Vault locked'); }; $('#vtsync').onclick = async () => { try { await vtReq('/sync', {}); await load(); toast('Refreshed'); } catch (e) { e.locked ? lockedOut() : toast(e.message); } }; let timer; $('#vtq').oninput = e => { vtState.q = e.target.value; clearTimeout(timer); timer = setTimeout(load, 250); }; @@ -64,12 +67,12 @@ async function vtList(st) { $('#vtfo').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); vtState.folder = a.dataset.f; draw(); }); const rows = vtState.items.filter(i => !vtState.folder || i.folderId === vtState.folder); $('#vtl').innerHTML = rows.length ? `
${rows.map(i => ``).join('')}
` : `

${vtState.q ? 'Nothing matches.' : 'Your vault is empty.'}

`; - $('#vtl').querySelectorAll('.row').forEach(b => b.onclick = () => vtOpen(b.dataset.id, lockedOut)); + $('#vtl').querySelectorAll('.row').forEach(b => b.onclick = () => vtOpen(b.dataset.id, lockedOut, load)); } if (vtState.items) draw(); load(); } -async function vtOpen(id, lockedOut) { +async function vtOpen(id, lockedOut, reload) { let it; try { it = await vtReq('/item/' + encodeURIComponent(id)); } catch (e) { return e.locked ? lockedOut() : toast(e.message); } const field = (label, value, key, secret) => value ? `
${esc(label)}
${secret ? '••••••••••' : esc(value)}${secret ? `` : ''}
` : ''; const vals = { user: it.username, pass: it.password, totp: it.totp }; @@ -81,7 +84,91 @@ async function vtOpen(id, lockedOut) { ${(it.fields || []).map((f, n) => field(f.name || 'Field', f.value, 'f' + n, f.hidden)).join('')} ${links.map(u => `
Website
${esc(u)}
`).join('')} ${it.notes ? `
Notes
${esc(it.notes)}
` : ''}`); + if (it.type === 1 || it.type === 2) el.insertAdjacentHTML('beforeend', '
'); + if ($('#vted')) $('#vted').onclick = () => { closeSheet(); vtEdit(it, reload, lockedOut); }; Object.assign(vals, { chold: it.card && it.card.holder, cexp: it.card && [it.card.expMonth, it.card.expYear].filter(Boolean).join('/') }); el.querySelectorAll('.vtcp').forEach(b => b.onclick = () => vtCopy(vals[b.dataset.k], b.dataset.w)); el.querySelectorAll('.vtshow').forEach(b => b.onclick = () => { const s = el.querySelector(`.vtv[data-k="${b.dataset.k}"]`), on = b.textContent === 'Show'; s.textContent = on ? vals[b.dataset.k] : '••••••••••'; b.textContent = on ? 'Hide' : 'Show'; }); } + +/** Make a random password or passphrase. */ +function vtGenerate() { + const o = { type: 'password', length: 16, upper: 1, lower: 1, number: 1, special: 1, words: 4, capitalize: 1, inum: 0 }; + const el = openSheet('Generate a password', 'Made on the server by the Bitwarden tool. Nothing is saved.', `
PasswordPassphrase
+
+
+
`); + let value = ''; + const gen = async () => { + const q = new URLSearchParams(o.type === 'password' ? { type: 'password', length: o.length, upper: o.upper, lower: o.lower, number: o.number, special: o.special } : { type: 'passphrase', words: o.words, capitalize: o.capitalize, number: o.inum }); + try { value = (await vtReq('/generate?' + q)).value; el.querySelector('#vgv').textContent = value; el.querySelector('#vge').textContent = ''; } catch (e) { el.querySelector('#vge').textContent = e.message; } + }; + const opts = () => { + el.querySelector('#vgo').innerHTML = o.type === 'password' + ? `${[['upper', 'A-Z'], ['lower', 'a-z'], ['number', '0-9'], ['special', '!@#$']].map(([k, l]) => ``).join('')}` + : ``; + const r = el.querySelector('#vgl') || el.querySelector('#vgw'); r.oninput = () => { o[o.type === 'password' ? 'length' : 'words'] = +r.value; r.previousElementSibling.querySelector('b').textContent = r.value; }; r.onchange = gen; + el.querySelectorAll('#vgo input[type=checkbox]').forEach(c => c.onchange = () => { o[c.dataset.k] = c.checked ? 1 : 0; gen(); }); + }; + el.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); o.type = a.dataset.t; el.querySelectorAll('.seg a').forEach(x => x.classList.toggle('on', x === a)); opts(); gen(); }); + el.querySelector('#vgc').onclick = () => value && vtCopy(value, 'Password'); el.querySelector('#vgr').onclick = gen; + opts(); gen(); +} + +/** Bitwarden Send: a link that shows some text once (or a few times) and then goes away. */ +async function vtSend(lockedOut) { + const el = openSheet('Send', 'Share some text through a private link that deletes itself.', `
+ + +
+
0 for no limit on views. + +

Your sends

Loading…

`); + const drawList = async () => { + try { const r = await vtReq('/sends'); el.querySelector('#vsl').innerHTML = r.sends.length ? `
${r.sends.map(x => `
${esc(x.name)}${x.max ? x.views + ' of ' + x.max + ' views' : x.views + ' views'}${x.expires ? ' · deletes ' + esc(new Date(x.expires).toLocaleDateString()) : ''}
`).join('')}
` : '

No sends yet.

'; + el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => vtCopy(b.dataset.c, 'Link')); + el.querySelectorAll('[data-d]').forEach(b => b.onclick = async () => { try { await vtReq('/sends/delete', { id: b.dataset.d }); drawList(); } catch (e) { toast(e.message); } }); } + catch (e) { if (e.locked) { closeSheet(); return lockedOut(); } el.querySelector('#vsl').innerHTML = `

${esc(e.message)}

`; } + }; + el.querySelector('#vsf').onsubmit = async e => { + e.preventDefault(); el.querySelector('#vse').textContent = ''; el.querySelector('#vsg').disabled = true; + try { const r = await vtReq('/sends', { name: el.querySelector('#vsn').value, text: el.querySelector('#vst').value, days: el.querySelector('#vsd').value, max: el.querySelector('#vsm').value, password: el.querySelector('#vsp').value }); + el.querySelector('#vst').value = ''; el.querySelector('#vsp').value = ''; + el.querySelector('#vsr').innerHTML = `
Your link
${esc(r.url)}
`; + el.querySelector('#vscp').onclick = () => vtCopy(r.url, 'Link'); vtCopy(r.url, 'Link'); drawList(); } + catch (err) { if (err.locked) { closeSheet(); return lockedOut(); } el.querySelector('#vse').textContent = err.message; } + el.querySelector('#vsg').disabled = false; + }; + drawList(); +} + +/** Add an entry (it === null) or change one. Logins and secure notes; cards and identities are changed on the Vaultwarden site. */ +function vtEdit(it, reload, lockedOut) { + const isNew = !it, type = it ? it.type : 1; + const el = openSheet(isNew ? 'New entry' : 'Edit ' + (it.name || 'entry'), '', `
+ ${isNew ? '' : ''} + +
+ +
+ + +
+ + + + +
${isNew ? '' : ''}
`); + let t = type; const sync = () => { el.querySelector('#vel').hidden = t === 2; }; + el.querySelectorAll('#vet a').forEach(a => a.onclick = e => { e.preventDefault(); t = +a.dataset.t; el.querySelectorAll('#vet a').forEach(x => x.classList.toggle('on', x === a)); sync(); }); sync(); + if (it && it.type !== 1) el.querySelector('#vel').hidden = true; + el.querySelector('#veg').onclick = async () => { try { el.querySelector('#vep').value = (await vtReq('/generate?type=password&length=20&upper=1&lower=1&number=1&special=1')).value; } catch (e) { toast(e.message); } }; + el.querySelector('#vef').onsubmit = async e => { + e.preventDefault(); el.querySelector('#vee').textContent = ''; el.querySelector('#ves').disabled = true; + const q = id => el.querySelector(id).value; + try { await vtReq('/item', { id: it && it.id, type: t, name: q('#ven'), username: q('#veu'), password: q('#vep'), uris: [q('#vew')], totp: q('#vek'), notes: q('#vex'), folderId: q('#vef2'), favorite: el.querySelector('#vefv').checked }); + closeSheet(); toast(isNew ? 'Added' : 'Saved'); reload && reload(); } + catch (err) { if (err.locked) { closeSheet(); return lockedOut(); } el.querySelector('#vee').textContent = err.message; el.querySelector('#ves').disabled = false; } + }; + const del = el.querySelector('#ved'); if (del) del.onclick = () => confirmSheet('Delete this entry?', 'It moves to the vault\'s trash on the Vaultwarden site, where it can be restored.', 'Delete', async () => { try { await vtReq('/item/delete', { id: it.id }); toast('Deleted'); reload && reload(); } catch (err) { toast(err.message); } }); +} diff --git a/pwvault.js b/pwvault.js index a41c647..23dcd73 100644 --- a/pwvault.js +++ b/pwvault.js @@ -21,14 +21,17 @@ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const appdata = uid => { const d = path.join(users.dirOf(uid), 'bw'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; }; /** Run `bw` as one person. Secrets travel in the environment only. */ -function bw(uid, args, { session, password, code, timeout = 45e3 } = {}) { +function bw(uid, args, { session, password, input, timeout = 45e3 } = {}) { const env = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', BITWARDENCLI_APPDATA_DIR: appdata(uid), BW_NOINTERACTION: 'true', NODE_NO_WARNINGS: '1' }; if (session) env.BW_SESSION = session; if (password != null) env.BW_PASSWORD = password; - const run = () => new Promise((resolve, reject) => execFile(BW, args, { env, timeout, maxBuffer: 16e6 }, (err, stdout, stderr) => { - if (err) { const e = new Error(String(stderr || err.message).trim().split('\n').pop() || 'The vault did not answer'); e.stdout = stdout; return reject(e); } - resolve(String(stdout)); - })); + const run = () => new Promise((resolve, reject) => { + const child = execFile(BW, args, { env, timeout, maxBuffer: 16e6 }, (err, stdout, stderr) => { + if (err) { const e = new Error(String(stderr || err.message).trim().split('\n').pop() || 'The vault did not answer'); e.stdout = stdout; return reject(e); } + resolve(String(stdout)); + }); + if (input != null) child.stdin.end(input); // (a Send's text goes in on stdin, so it never shows in the process list) + }); const prev = chains.get(uid) || Promise.resolve(), p = prev.then(run, run); chains.set(uid, p.catch(() => {})); return p; @@ -45,6 +48,49 @@ const asLocked = e => { if (!e.code && /session|locked|not logged in|unauthentic const brief = i => ({ id: i.id, name: i.name || '', type: i.type, favorite: !!i.favorite, folderId: i.folderId || null, username: i.login ? i.login.username || '' : '', uri: i.login && i.login.uris && i.login.uris[0] ? i.login.uris[0].uri || '' : '', hasTotp: !!(i.login && i.login.totp) }); +const int = (v, lo, hi, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; }; +/** A new random password or passphrase from bw (needs no unlocked vault). */ +async function generate(uid, q) { + const args = ['generate']; + if (q.get('type') === 'passphrase') { + args.push('--passphrase', '--words', String(int(q.get('words'), 3, 20, 4)), '--separator', String(q.get('separator') || '-').slice(0, 1)); + if (q.get('capitalize') === '1') args.push('--capitalize'); if (q.get('number') === '1') args.push('--includeNumber'); + } else { + const flags = ['upper', 'lower', 'number', 'special'].filter(k => q.get(k) !== '0').map(k => ({ upper: 'u', lower: 'l', number: 'n', special: 's' })[k]); + args.push('-' + (flags.join('') || 'l'), '--length', String(int(q.get('length'), 5, 128, 16))); + } + return { value: (await bw(uid, args, { timeout: 15e3 })).trim() }; +} +const enc = o => Buffer.from(JSON.stringify(o)).toString('base64'); +const clip = (v, n) => String(v == null ? '' : v).slice(0, n); +/** Make or change a login or secure note. A change starts from the entry as it is now, so custom fields and anything not shown here are kept. */ +async function saveItem(uid, b, key) { + const name = clip(b.name, 1000).trim(); if (!name) throw fail(400, 'Give the entry a name'); + const folderId = b.folderId && ID.test(b.folderId) ? b.folderId : null; + const uris = (Array.isArray(b.uris) ? b.uris : []).map(u => clip(u, 1000).trim()).filter(Boolean).slice(0, 10); + let item; + if (b.id) { if (!ID.test(b.id)) throw fail(400, 'Bad item'); item = parse(await bw(uid, ['get', 'item', b.id], { session: key })); } + else { const type = +b.type === 2 ? 2 : 1; item = { organizationId: null, collectionIds: null, type, fields: [], reprompt: 0, login: type === 1 ? {} : undefined, secureNote: type === 2 ? { type: 0 } : undefined }; } + item.name = name; item.notes = clip(b.notes, 10000) || null; item.folderId = folderId; item.favorite = !!b.favorite; + if (item.type === 1) { + const l = item.login = item.login || {}; l.username = clip(b.username, 1000) || null; l.password = clip(b.password, 1000) || null; l.totp = clip(b.totp, 1000).trim() || null; + const old = l.uris || []; l.uris = uris.map(u => ({ ...(old.find(o => o.uri === u) || { match: null }), uri: u })); + } + const out = parse(await bw(uid, b.id ? ['edit', 'item', b.id] : ['create', 'item'], { session: key, input: enc(item) })); + bw(uid, ['sync'], { session: key }).catch(() => {}); + return { id: out.id }; +} +const sendBrief = x => ({ id: x.id, name: x.name || '', url: x.accessUrl || '', expires: x.deletionDate || '', views: x.accessCount || 0, max: x.maxAccessCount || 0, hasPassword: !!x.password || !!x.hasPassword, disabled: !!x.disabled }); +/** A text Send: a link that shows the text once (or a few times) and then goes away. */ +async function createSend(uid, b, key) { + const text = String(b.text || '').slice(0, 10000); if (!text.trim()) throw fail(400, 'Type what you want to send'); + const days = int(b.days, 1, 31, 7), max = int(b.max, 0, 9999, 0); + const body = { type: 0, name: String(b.name || '').trim().slice(0, 100) || 'Text from NE Hub', notes: null, text: { text, hidden: !!b.hidden }, file: null, key: null, + maxAccessCount: max || null, deletionDate: new Date(Date.now() + days * 864e5).toISOString(), expirationDate: null, password: b.password ? String(b.password).slice(0, 200) : null, disabled: false, hideEmail: false }; + const out = parse(await bw(uid, ['send', 'create'], { session: key, input: Buffer.from(JSON.stringify(body)).toString('base64') })); + return sendBrief(out); +} + async function status(uid) { let st = { status: 'unauthenticated' }; try { st = parse(await bw(uid, ['status'], { timeout: 15e3 })); } catch {} const s = sessions.get(uid); @@ -86,6 +132,12 @@ async function handle(req, res) { if (p === '/lock' && req.method === 'POST') { lock(uid); return json(res, 200, { ok: true }); } if (p === '/forget' && req.method === 'POST') { lock(uid); await bw(uid, ['logout']).catch(() => {}); return json(res, 200, { ok: true }); } // sign this account out of the hub completely if (p === '/sync' && req.method === 'POST') { try { await bw(uid, ['sync'], { session: keyOf(uid) }); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); } + if (p === '/item' && req.method === 'POST') { const key = keyOf(uid); try { return json(res, 200, await saveItem(uid, JSON.parse(await readBody(req, 5e4) || '{}'), key)); } catch (e) { throw asLocked(e); } } + if (p === '/item/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); const key = keyOf(uid); try { await bw(uid, ['delete', 'item', id], { session: key }); } catch (e) { throw asLocked(e); } bw(uid, ['sync'], { session: key }).catch(() => {}); return json(res, 200, { ok: true }); } // goes to the vault's trash, where it can still be restored + if (p === '/generate' && req.method === 'GET') return json(res, 200, await generate(uid, q)); + if (p === '/sends' && req.method === 'GET') { try { return json(res, 200, { sends: parse(await bw(uid, ['send', 'list'], { session: keyOf(uid) })).filter(x => x.type === 0).map(sendBrief) }); } catch (e) { throw asLocked(e); } } + if (p === '/sends' && req.method === 'POST') { const key = keyOf(uid); try { return json(res, 200, await createSend(uid, JSON.parse(await readBody(req, 5e4) || '{}'), key)); } catch (e) { throw asLocked(e); } } + if (p === '/sends/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); try { await bw(uid, ['send', 'delete', id], { session: keyOf(uid) }); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); } if (p === '/items' && req.method === 'GET') { const key = keyOf(uid), search = String(q.get('q') || '').slice(0, 100).trim(), args = ['list', 'items']; if (search) args.push('--search=' + search); @@ -102,7 +154,7 @@ async function handle(req, res) { const i = parse(await bw(uid, ['get', 'item', m[1]], { session: key })), l = i.login || {}; let totp = ''; if (l.totp) { try { totp = (await bw(uid, ['get', 'totp', m[1]], { session: key })).trim(); } catch {} } const c = i.card || {}; - return json(res, 200, { id: i.id, name: i.name || '', type: i.type, notes: i.notes || '', username: l.username || '', password: l.password || '', totp, + return json(res, 200, { id: i.id, name: i.name || '', type: i.type, notes: i.notes || '', username: l.username || '', password: l.password || '', totp, totpSeed: l.totp || '', folderId: i.folderId || '', favorite: !!i.favorite, uris: (l.uris || []).map(x => x.uri).filter(Boolean), card: i.type === 3 ? { holder: c.cardholderName || '', brand: c.brand || '', number: c.number || '', code: c.code || '', expMonth: c.expMonth || '', expYear: c.expYear || '' } : null, fields: (i.fields || []).filter(f => f.type !== 3).map(f => ({ name: f.name || '', value: f.value || '', hidden: f.type === 1 })) });