bwpass: Initial implementation
The `bwpass` command attempts to replicate the main functionality of `pass` on top of `bw`. Since `bw` is incredibly slow, it tries to avoid spawning it whenever possible by caching the list of items. It also manages the Bitwarden CLI session by reading the session token from a file. If the file does not exist, it will prompt for the vault master password using `pinentry`, unlock the session, and store the new token.master
parent
8ad267cc0e
commit
785be5424f
|
@ -0,0 +1,5 @@
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable =
|
||||||
|
invalid-name,
|
||||||
|
missing-docstring,
|
||||||
|
too-few-public-methods,
|
5
setup.py
5
setup.py
|
@ -13,4 +13,9 @@ setup(
|
||||||
setup_requires=[
|
setup_requires=[
|
||||||
'setuptools_scm',
|
'setuptools_scm',
|
||||||
],
|
],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'bwpass=bwpass:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
280
src/bwpass.py
280
src/bwpass.py
|
@ -0,0 +1,280 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
from typing import (
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
)
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('bwpass')
|
||||||
|
|
||||||
|
|
||||||
|
LOG_FORMAT = (
|
||||||
|
'%(asctime)s [%(name)s] %(threadName)s %(levelname)s %(message)s'
|
||||||
|
)
|
||||||
|
PINENTRY_DEBUG = os.environ.get('PINENTRY_DEBUG')
|
||||||
|
XDG_CACHE_HOME = os.environ.get(
|
||||||
|
'XDG_CACHE_HOME',
|
||||||
|
os.path.expanduser('~/.cache'),
|
||||||
|
)
|
||||||
|
|
||||||
|
BITWARDENCLI_APPDATA_DIR = os.environ.get(
|
||||||
|
'BITWARDENCLI_APPDATA_DIR',
|
||||||
|
os.path.join(XDG_CACHE_HOME, 'bitwarden'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BitwardenError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PinentryError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MainArguments:
|
||||||
|
|
||||||
|
quiet: int
|
||||||
|
verbose: int
|
||||||
|
args: Sequence[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Pinentry:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title: str = None,
|
||||||
|
description: str = None,
|
||||||
|
prompt: str = None,
|
||||||
|
) -> None:
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.prompt = prompt
|
||||||
|
|
||||||
|
def getpin(self):
|
||||||
|
codec = locale.getpreferredencoding()
|
||||||
|
p = subprocess.Popen(
|
||||||
|
['pinentry'],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def getline():
|
||||||
|
line = p.stdout.readline().rstrip(b'\n').decode(codec)
|
||||||
|
if PINENTRY_DEBUG:
|
||||||
|
log.debug('pinentry: %s', line)
|
||||||
|
if line.startswith('ERR'):
|
||||||
|
raise PinentryError(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def putline(line):
|
||||||
|
p.stdin.write(line.encode(codec) + b'\n')
|
||||||
|
p.stdin.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with p.stdin, p.stdout:
|
||||||
|
getline()
|
||||||
|
if self.title:
|
||||||
|
putline(f'SETTITLE {self.title}')
|
||||||
|
getline()
|
||||||
|
if self.description:
|
||||||
|
putline(f'SETDESC {self.description}')
|
||||||
|
getline()
|
||||||
|
if self.prompt:
|
||||||
|
putline(f'SETTITLE {self.title}')
|
||||||
|
getline()
|
||||||
|
putline('GETPIN')
|
||||||
|
d = getline()
|
||||||
|
if not d.startswith('D '):
|
||||||
|
raise PinentryError(
|
||||||
|
f'Unexpected response from pinentry: {d}'
|
||||||
|
)
|
||||||
|
pin = d[2:]
|
||||||
|
getline()
|
||||||
|
finally:
|
||||||
|
p.wait()
|
||||||
|
return pin
|
||||||
|
|
||||||
|
|
||||||
|
class Vault:
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.items: Mapping[str, str] = {}
|
||||||
|
self.session_id: Optional[str] = None
|
||||||
|
|
||||||
|
self.bw_data = os.path.join(BITWARDENCLI_APPDATA_DIR, 'data.json')
|
||||||
|
self.cache = os.path.join(BITWARDENCLI_APPDATA_DIR, 'bwpass.json')
|
||||||
|
|
||||||
|
def __enter__(self) -> 'Vault':
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, tb):
|
||||||
|
with open(self.cache, 'w') as f:
|
||||||
|
json.dump(self.items, f)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls) -> 'Vault':
|
||||||
|
self = cls()
|
||||||
|
try:
|
||||||
|
st = os.stat(self.cache)
|
||||||
|
st2 = os.stat(self.bw_data)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if st.st_mtime >= st2.st_mtime:
|
||||||
|
self.load_cache()
|
||||||
|
return self
|
||||||
|
self.unlock()
|
||||||
|
self.get_items()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def get_items(self):
|
||||||
|
folders: Mapping[str, str] = {}
|
||||||
|
for folder in json.loads(self._run('list', 'folders')):
|
||||||
|
folders[folder['id']] = folder['name']
|
||||||
|
for item in json.loads(self._run('list', 'items')):
|
||||||
|
if item['folderId'] is None:
|
||||||
|
key = item['name']
|
||||||
|
else:
|
||||||
|
key = f'{folders[item["folderId"]]}/{item["name"]}'
|
||||||
|
self.items[key] = item['id']
|
||||||
|
|
||||||
|
def get_password(self, item):
|
||||||
|
item_id = self.items[item]
|
||||||
|
log.debug('Getting password for %s', item_id)
|
||||||
|
if not self.session_id:
|
||||||
|
self.unlock()
|
||||||
|
return self._run('get', 'password', item_id)
|
||||||
|
|
||||||
|
def load_cache(self) -> None:
|
||||||
|
log.debug('Loading items from cache')
|
||||||
|
with open(self.cache) as f:
|
||||||
|
self.items = json.load(f)
|
||||||
|
|
||||||
|
def unlock(self) -> None:
|
||||||
|
uid = os.getuid()
|
||||||
|
fn = os.path.join(
|
||||||
|
tempfile.gettempdir(),
|
||||||
|
f'.bw_session-{uid}',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(fn) as f:
|
||||||
|
log.debug('Loading session ID from %s', fn)
|
||||||
|
self.session_id = f.readline().rstrip('\n')
|
||||||
|
return
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pinentry = Pinentry(
|
||||||
|
title='Unlock Vault',
|
||||||
|
description='Enter the master password to unlock the Vault',
|
||||||
|
prompt='Master password:',
|
||||||
|
)
|
||||||
|
for __ in range(3):
|
||||||
|
try:
|
||||||
|
log.debug('Getting master password')
|
||||||
|
password = pinentry.getpin()
|
||||||
|
self.session_id = self._run('unlock', '--raw', stdin=password)
|
||||||
|
except PinentryError as e:
|
||||||
|
log.error('Failed to get master password: %s', e)
|
||||||
|
break
|
||||||
|
except BitwardenError as e:
|
||||||
|
log.error('Error unlocking vault: %s', e)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if self.session_id:
|
||||||
|
fd = os.open(fn, os.O_WRONLY | os.O_CREAT, 0o0400)
|
||||||
|
with open(fd, 'w') as f:
|
||||||
|
f.write(self.session_id)
|
||||||
|
|
||||||
|
def _run(self, *args, stdin: str = None):
|
||||||
|
stdin_bytes = stdin.encode('utf-8') if stdin else None
|
||||||
|
cmd = ['bw']
|
||||||
|
cmd += args
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['BITWARDENCLI_APPDATA_DIR'] = BITWARDENCLI_APPDATA_DIR
|
||||||
|
if self.session_id:
|
||||||
|
env['BW_SESSION'] = self.session_id
|
||||||
|
log.debug('Running command: %s', cmd)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE if stdin else subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
data = p.communicate(stdin_bytes)[0].decode('utf-8')
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise BitwardenError(data.rstrip('\n'))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
g_verb = parser.add_mutually_exclusive_group()
|
||||||
|
g_verb.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='count',
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
g_verb.add_argument(
|
||||||
|
'--quiet', '-q',
|
||||||
|
action='count',
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'args',
|
||||||
|
nargs='*',
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args(namespace=MainArguments())
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(verbose: int = 0) -> None:
|
||||||
|
if verbose < -1:
|
||||||
|
level = logging.ERROR
|
||||||
|
elif verbose < 0:
|
||||||
|
level = logging.WARNING
|
||||||
|
elif verbose < 1:
|
||||||
|
level = logging.INFO
|
||||||
|
else:
|
||||||
|
level = logging.DEBUG
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setLevel(level)
|
||||||
|
handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(level)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
setup_logging(args.verbose or args.quiet * -1)
|
||||||
|
|
||||||
|
with Vault.load() as vault:
|
||||||
|
if args.args:
|
||||||
|
if len(args.args) == 1:
|
||||||
|
args.args.insert(0, 'show')
|
||||||
|
if args.args[0] == 'show':
|
||||||
|
item = args.args[1]
|
||||||
|
try:
|
||||||
|
print(vault.get_password(item), end='')
|
||||||
|
except KeyError:
|
||||||
|
print('No such item:', item, file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print('\n'.join(sorted(vault.items)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
Reference in New Issue