// 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 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, code, 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 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); 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) }); 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) }); } 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 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 = /^\/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 = 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, 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_ };