diff --git a/README.md b/README.md index 1789299..3041316 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # bitwarden-dmenu +[![npm](https://img.shields.io/npm/v/bitwarden-dmenu.svg)](https://www.npmjs.com/package/bitwarden-dmenu) [![node](https://img.shields.io/node/v/bitwarden-dmenu.svg)](http://npmjs.com/package/bitwarden-dmenu) [![GitHub](https://img.shields.io/github/license/andykais/bitwarden-dmenu.svg)](https://github.com/andykais/bitwarden-dmenu/blob/master/LICENSE) - - dmenu for [bitwarden](https://bitwarden.com/) which can copy usernames, passwords, and various fields to your clipboard. ## Usage + ``` $ bitwarden-dmenu --help Usage: bitwarden-dmenu [options] @@ -22,7 +22,10 @@ Options: current time. Defaults to 0s. --on-error Arbitrary command to run if the program fails. The thrown error is piped to the given command. Defaults to none. + + --verbose Show extra logs useful for debugging. ``` + By default, this program runs at its most secure. No session is stored for any time period, the vault is updated every time it is used, and the clipboard is cleared every 15 seconds. In reality, you may want something a little more lenient. Here is the command I use in my personal i3wm config. @@ -32,6 +35,7 @@ bitwarden-dmenu --clear-clipboard 30 --session-timeout 100 --sync-vault-after 36 ``` ## Installation + ```bash # login with bitwarden-cli once before using bitwarden-dmenu bw login @@ -40,6 +44,7 @@ npm i -g bitwarden-dmenu ``` ## Depends on + - [dmenu](https://tools.suckless.org/dmenu/) - [bitwarden-cli](https://help.bitwarden.com/article/cli/) diff --git a/bin/cli.js b/bin/cli.js index f57acf7..2c2753f 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -24,6 +24,8 @@ Options: current time. Defaults to ${syncVaultAfterDefault}s. --on-error Arbitrary command to run if the program fails. The thrown error is piped to the given command. Defaults to none. + + --verbose Show extra logs useful for debugging. ` ) process.exit() @@ -33,6 +35,9 @@ const clearClipboardAfter = args['clear-clipboard'] || cachePasswordDefault const sessionTimeout = args['session-timeout'] || sessionTimeoutDefault const syncVaultAfter = args['sync-vault-after'] || syncVaultAfterDefault const onErrorCommand = args['on-error'] +console.debug = args['verbose'] + ? (...msgs) => console.log(...msgs, '\n') + : () => {} const oldestAllowedVaultSync = syncVaultAfter const saveSession = Boolean(sessionTimeout) diff --git a/package.json b/package.json index 87320a7..4237a35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bitwarden-dmenu", - "version": "1.0.1", + "version": "1.1.0", "description": "", "keywords": [ "bitwarden", @@ -13,7 +13,7 @@ }, "license": "MIT", "author": "Andrew Kaiser", - "main": "index.js", + "main": "src/index.js", "bin": { "bitwarden-dmenu": "bin/cli.js" }, diff --git a/src/exec-bitwarden-cli.js b/src/exec-bitwarden-cli.js index 6afa35f..a963da2 100644 --- a/src/exec-bitwarden-cli.js +++ b/src/exec-bitwarden-cli.js @@ -1,10 +1,13 @@ const path = require('path') const { execSync } = require('child_process') +const obfuscate = require('./util/obfuscate/bitwarden-cli') const bwExecutable = path.resolve(__dirname, '../node_modules/.bin/bw') module.exports = args => { + const execCommand = `${bwExecutable} ${args}` + console.debug('$', obfuscate(execCommand)) try { - const stdout = execSync(`${bwExecutable} ${args}`) + const stdout = execSync(execCommand) return stdout.toString().replace(/\n$/, '') } catch (e) { throw new Error(e.stdout.toString().trim()) diff --git a/src/exec-dmenu.js b/src/exec-dmenu.js index 85602cc..a8a7800 100644 --- a/src/exec-dmenu.js +++ b/src/exec-dmenu.js @@ -3,9 +3,11 @@ const { exec, execSync, spawn } = require('child_process') module.exports = (choices = '\n', args = '') => new Promise((resolve, reject) => { let choice = '' - let error = [] + const error = [] - const dmenu = exec(`dmenu ${args}`) + const execCommand = `dmenu ${args}` + console.debug('$', execCommand) + const dmenu = exec(execCommand) dmenu.stdin.write(choices) dmenu.stdin.end() diff --git a/src/index.js b/src/index.js index fe0a8ac..a9cec4f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,23 +1,29 @@ const { existsSync, writeFileSync, readFileSync } = require('fs') +const clipboardy = require('clipboardy') const dmenuRun = require('./exec-dmenu') const bwRun = require('./exec-bitwarden-cli') -const clipboardy = require('clipboardy') +const obfuscate = require('./util/obfuscate/object') +// get a session token, either from existing sessionFile or by `bw unlock [password]` const getSessionVar = async ({ saveSession, sessionFile }) => { if (saveSession) { + console.debug(`checking for session file at ${sessionFile}`) const sessionFileExists = existsSync(sessionFile) if (sessionFileExists) { const session = readFileSync(sessionFile) .toString() .replace(/\n$/, '') + console.debug('read existing session file.') return session } else { + console.debug('no session file found.') // prompt for password in dmenu const password = await dmenuRun('\n', '-p Password: -nf black -nb black') if (!password) throw new Error('no password given!') const session = bwRun(`unlock '${password}' --raw`) writeFileSync(sessionFile, session) + console.debug('saved new session file.') return session } } else { @@ -28,33 +34,37 @@ const getSessionVar = async ({ saveSession, sessionFile }) => { } } -module.exports = async ({ - saveSession, - sessionFile, - oldestAllowedVaultSync -}) => { - const session = await getSessionVar({ saveSession, sessionFile }) - console.log({ session }) - - // bw sync if necessary +// sync the password accounts with the remote server +// if --sync-vault-after < time since the last sync +const syncIfNecessary = ({ session, oldestAllowedVaultSync }) => { const last = bwRun(`sync --last --session=${session}`) const timeSinceSync = (new Date().getTime() - new Date(last).getTime()) / 1000 if (timeSinceSync > oldestAllowedVaultSync) { + console.debug('syncing vault...') bwRun(`sync --session=${session}`) + console.debug(`sync complete, last sync was ${last}`) } - console.log('synced') +} - // bw list +// get the list all password accounts in the vault +const getAccounts = ({ session }) => { const listStr = bwRun(`list items --session=${session}`) const list = JSON.parse(listStr) - const accountNames = list.map(a => `${a.name}: ${a.login.username}`) + return list +} - // choose account in dmenu +// choose one account with dmenu +const chooseAccount = async ({ list }) => { + const accountNames = list.map(a => `${a.name}: ${a.login.username}`) const selected = await dmenuRun(accountNames.join('\n')) const index = accountNames.indexOf(selected) const selectedAccount = list[index] + console.debug('selected account:\n', obfuscate(selectedAccount)) + return selectedAccount +} - // choose field to copy in dmenu +// choose one field with dmenu +const chooseField = async ({ selectedAccount }) => { const copyable = { password: selectedAccount.login.password, username: selectedAccount.login.username, @@ -69,6 +79,27 @@ module.exports = async ({ } const field = await dmenuRun(Object.keys(copyable).join('\n')) const valueToCopy = copyable[field] + return valueToCopy +} + +module.exports = async ({ + saveSession, + sessionFile, + oldestAllowedVaultSync +}) => { + const session = await getSessionVar({ saveSession, sessionFile }) + + // bw sync if necessary + syncIfNecessary({ session, oldestAllowedVaultSync }) + + // bw list + const list = getAccounts({ session }) + + // choose account in dmenu + const selectedAccount = await chooseAccount({ list }) + + // choose field to copy in dmenu + const valueToCopy = await chooseField({ selectedAccount }) // copy to clipboard clipboardy.writeSync(valueToCopy) diff --git a/src/schedule-cleanup.js b/src/schedule-cleanup.js index b9316c7..e37e571 100644 --- a/src/schedule-cleanup.js +++ b/src/schedule-cleanup.js @@ -9,21 +9,23 @@ const timeout = n => new Promise(resolve => setTimeout(resolve, n)) * */ -module.exports = ({ lockBitwardenAfter, clearClipboardAfter, sessionFile }) => - Promise.all([ +module.exports = ({ lockBitwardenAfter, clearClipboardAfter, sessionFile }) => { + console.debug('begin cleanup') + return Promise.all([ timeout(lockBitwardenAfter * 1000).then(() => { try { fs.unlinkSync(sessionFile) - console.log(`${sessionFile} removed.`) + console.debug(`${sessionFile} removed.`) } catch (e) { if (e.code !== 'ENOENT') throw e - console.log(`${sessionFile} already removed.`) + console.debug(`${sessionFile} already removed.`) } bwRun('lock') - console.log('bitwarden is locked.') + console.info('bitwarden is locked.') }), timeout(clearClipboardAfter * 1000).then(() => { clipboardy.writeSync('') - console.log('clipboard is cleared.') + console.info('clipboard is cleared.') }) ]) +} diff --git a/src/util/obfuscate/bitwarden-cli.js b/src/util/obfuscate/bitwarden-cli.js new file mode 100644 index 0000000..5db1d89 --- /dev/null +++ b/src/util/obfuscate/bitwarden-cli.js @@ -0,0 +1,4 @@ +module.exports = command => + command + .replace(/unlock\s'.*'/, `unlock '******'`) + .replace(/session=.*/, 'session=****** ') diff --git a/src/util/obfuscate/object.js b/src/util/obfuscate/object.js new file mode 100644 index 0000000..0c5cefa --- /dev/null +++ b/src/util/obfuscate/object.js @@ -0,0 +1,35 @@ +const isArray = val => Array.isArray(val) + +const isObject = val => val !== null && typeof val === 'object' + +const traverseObject = applyFunc => any => { + if (isObject(any)) { + for (const key in any) { + const val = any[key] + any[key] = traverseObject(applyFunc)(val) + } + return any + } else if (isArray(any)) { + for (const i of any.keys()) { + const val = any[i] + any[i] = traverseObject(applyFunc)(val) + } + return any + } else { + return applyFunc(any) + } +} + +const replaceNonNullValues = traverseObject(v => { + if (v === null || v === undefined) { + return v + } else { + return '******' + } +}) + +module.exports = object => { + const copied = JSON.parse(JSON.stringify(object)) + const obfuscatedObject = replaceNonNullValues(copied) + return JSON.stringify(obfuscatedObject, null, 2) +}