// Passwords (not vault.js, which encrypts stored tokens): each person's Vaultwarden / Bitwarden passwords, through the Bitwarden command-line tool (`bw`). // The vault is end-to-end encrypted, so the hub only ever holds what a person types in to unlock it: the master password goes to `bw` through its // environment (never the command line, never disk), and the session key `bw` hands back lives in memory only and is dropped after a few idle minutes. // Each person gets their own `bw` data folder, so one person's unlocked vault is never reachable from another's session. const fs = require('fs'), path = require('path'), { execFile } = require('child_process'); const users = require('./users'); const { readBody } = require('./body'); const URL_ = (process.env.VAULT_URL || '').replace(/\/+$/, ''); const BW = process.env.BW_BIN || 'bw'; const IDLE_MS = Math.max(1, +process.env.VAULT_IDLE_MIN || 10) * 60e3; const enabled = () => !!URL_; const sessions = new Map(); // user id -> { key, last } const chains = new Map(); // user id -> the last bw call, so one person's calls run one at a time const cache = new Map(); // user id -> Map(item id -> item) from the last list, so opening an entry needs no new bw call; dropped on lock, idle and any change const fails = new Map(); // user id -> { n, until }, slows down password guessing const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const fail = (code, message, extra) => Object.assign(new Error(message), { code, extra }); 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, 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) => { 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; } const parse = s => { try { return JSON.parse(s); } catch { throw fail(502, 'The vault gave an unexpected answer'); } }; const lock = uid => { const s = sessions.get(uid); sessions.delete(uid); cache.delete(uid); if (s) bw(uid, ['lock']).catch(() => {}); }; setInterval(() => { const now = Date.now(); for (const [uid, s] of sessions) if (now - s.last > IDLE_MS) lock(uid); }, 30e3).unref(); const keyOf = uid => { const s = sessions.get(uid); if (!s) throw fail(401, 'The vault is locked', { locked: true }); s.last = Date.now(); return s.key; }; /** A bw failure for something that needs an unlocked vault: if bw says the session is bad, treat it as locked. */ const asLocked = e => { if (!e.code && /session|locked|not logged in|unauthenticated/i.test(e.message)) return fail(401, 'The vault is locked', { locked: true }); return e; }; 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) })); cache.delete(uid); 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); return { enabled: true, url: URL_, unlocked: !!s && st.status === 'unlocked', email: st.userEmail || '', loggedIn: st.status !== 'unauthenticated' }; } async function unlock(uid, b) { const f = fails.get(uid); if (f && f.until > Date.now()) throw fail(429, 'Too many tries. Wait a few minutes and try again.'); const password = String(b.password || ''); if (!password) throw fail(400, 'Enter your master password'); const st = await status(uid); let out; try { if (!st.loggedIn) { const email = String(b.email || '').trim(); if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw fail(400, 'Enter the email address you use for the vault'); await bw(uid, ['config', 'server', URL_]); const args = ['login', email, '--passwordenv', 'BW_PASSWORD', '--raw']; if (b.code) args.push('--method', '0', '--code', String(b.code).replace(/\s/g, '').slice(0, 12)); // 0 = authenticator app out = await bw(uid, args, { password }); } else out = await bw(uid, ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], { password }); } catch (e) { if (e.code) throw e; if (/two-?step|two-?factor|2fa|code/i.test(e.message) && !b.code) throw fail(400, 'Enter the 6-digit code from your authenticator app', { need2fa: true }); const n = ((fails.get(uid) || {}).n || 0) + 1; fails.set(uid, { n, until: n >= 5 ? Date.now() + 10 * 60e3 : 0 }); if (n >= 5) fails.get(uid).n = 0; throw fail(401, /invalid|incorrect|password|username/i.test(e.message) ? 'That password did not work' : 'Could not unlock: ' + e.message); } fails.delete(uid); const key = out.trim(); if (!key) throw fail(502, 'The vault did not give a session'); sessions.set(uid, { key, last: Date.now() }); await bw(uid, ['sync'], { session: key }).catch(() => {}); return status(uid); } async function handle(req, res) { try { const u = users.current(); if (!u) throw fail(401, 'Sign in first'); if (!enabled()) throw fail(404, 'The vault is not set up (VAULT_URL)'); const uid = u.id, p = new URL(req.url, 'http://x').pathname.replace(/^\/api\/vault/, '') || '/', q = new URL(req.url, 'http://x').searchParams; if (p === '/status' && req.method === 'GET') return json(res, 200, await status(uid)); if (p === '/unlock' && req.method === 'POST') return json(res, 200, await unlock(uid, JSON.parse(await readBody(req, 1e4) || '{}'))); 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) }); cache.delete(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 }); cache.delete(uid); } 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); try { const items = parse(await bw(uid, args, { session: key })).filter(i => i.type === 1 || i.type === 2 || i.type === 3 || i.type === 4); // login, note, card, identity const cm = cache.get(uid) || new Map(); for (const i of items) cm.set(i.id, i); cache.set(uid, cm); let folders = []; try { folders = parse(await bw(uid, ['list', 'folders'], { session: key })).filter(f => f.id).map(f => ({ id: f.id, name: f.name })); } catch {} return json(res, 200, { items: items.map(brief).sort((a, b) => (b.favorite - a.favorite) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })).slice(0, 1000), folders }); } catch (e) { throw asLocked(e); } } let m; if ((m = /^\/totp\/([^/]+)$/.exec(p)) && req.method === 'GET') { if (!ID.test(m[1])) throw fail(400, 'Bad item'); try { return json(res, 200, { code: (await bw(uid, ['get', 'totp', m[1]], { session: keyOf(uid) })).trim() }); } catch (e) { throw asLocked(e); } } if ((m = /^\/item\/([^/]+)$/.exec(p)) && req.method === 'GET') { // one entry in full, only when a person opens it if (!ID.test(m[1])) throw fail(400, 'Bad item'); const key = keyOf(uid); try { const i = (cache.get(uid) || new Map()).get(m[1]) || parse(await bw(uid, ['get', 'item', m[1]], { session: key })), l = i.login || {}, 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 || '', hasTotp: !!l.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 })) }); } catch (e) { throw asLocked(e); } } return json(res, 404, { error: 'Not found' }); } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); } } module.exports = { handle, enabled, url: () => URL_ };