build-container / build (push) Successful in 5m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
116 lines
8.6 KiB
JavaScript
116 lines
8.6 KiB
JavaScript
// 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', BW_QUIET: '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_ };
|