440 lines
9.2 KiB
Python
440 lines
9.2 KiB
Python
import time
|
|
|
|
from datetime import datetime, timedelta
|
|
from izzylib import logging
|
|
from izzylib.datestring import DateString
|
|
from izzylib.exceptions import HttpClientError
|
|
from izzylib.http_client import HttpClient
|
|
from izzylib.misc import convert_to_boolean, convert_to_string, random_port
|
|
from json.decoder import JSONDecodeError
|
|
from nodejs import node, npm
|
|
|
|
from .item import BitwardenItem
|
|
from .result import BitwardenResult
|
|
|
|
from ..base import PasswordItem, PasswordStorage
|
|
|
|
from ... import scriptpath
|
|
from ...enums import *
|
|
from ...exceptions import BitwardenApiError, NoPasswordError
|
|
|
|
|
|
bwbin = scriptpath.join('bin/bw')
|
|
bwjs = scriptpath.parent.join('node_modules/@bitwarden/cli/build/bw.js')
|
|
|
|
|
|
def bool_str(value):
|
|
if convert_to_boolean(value):
|
|
return 'true'
|
|
|
|
return 'false'
|
|
|
|
|
|
def check_deps():
|
|
if not bwjs.exists():
|
|
npm.run(['install', '@bitwarden/cli'])
|
|
|
|
|
|
class BitwardenStorage(PasswordStorage):
|
|
item_class = BitwardenItem
|
|
result_class = BitwardenResult
|
|
|
|
|
|
def __init__(self, host='localhost', port=None, external=False, session_key=None):
|
|
self._proc = None
|
|
self._account = None
|
|
self._current_port = None
|
|
self._client = HttpClient(headers={
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json'
|
|
})
|
|
|
|
self.host = host
|
|
self.port = port
|
|
self.external = external
|
|
self.session_key = session_key
|
|
|
|
if not self.port:
|
|
if external:
|
|
self.port = 8087
|
|
|
|
else:
|
|
self.port = random_port(8000, 16000)
|
|
|
|
check_deps()
|
|
|
|
|
|
def __del__(self):
|
|
self.stop()
|
|
|
|
|
|
@property
|
|
def api_active(self):
|
|
return self.external or (self._proc and self._proc.poll() == None)
|
|
|
|
|
|
@property
|
|
def unlocked(self):
|
|
return True if self.session_key else False
|
|
|
|
|
|
@property
|
|
def account(self):
|
|
if not self._account:
|
|
self._account = self.status().userEmail
|
|
|
|
return self._account
|
|
|
|
## broken
|
|
#return self.status().status == 'unlocked'
|
|
|
|
|
|
def _process_response(self, raw_data, return_raw=False):
|
|
try:
|
|
data = DotDict(raw_data)
|
|
|
|
if return_raw:
|
|
return data
|
|
|
|
if 'success' not in data:
|
|
raise BitwardenApiError(data.message)
|
|
|
|
try:
|
|
return data.data
|
|
|
|
except AttributeError:
|
|
return data
|
|
|
|
|
|
except JSONDecodeError as e:
|
|
print(raw_data)
|
|
raise e from None
|
|
|
|
|
|
def get_port(self):
|
|
if self.port:
|
|
return self.port
|
|
|
|
if not self._current_port:
|
|
if self.external:
|
|
self._current_port = 8087
|
|
|
|
else:
|
|
self._current_port = random_port(8000, 16000)
|
|
|
|
return self._current_port
|
|
|
|
|
|
def create_row(self, *args, **kwargs):
|
|
return self.item_class.new(*args, **kwargs, storage=self)
|
|
|
|
|
|
## send cli command
|
|
def execute(self, command, *args, data=None, key=True, return_raw=False):
|
|
#cmd = [bwbin, '--response']
|
|
cmd = [bwjs, '--response']
|
|
|
|
if key:
|
|
if not self.session_key:
|
|
raise ValueError('Missing session key. Try calling login first.')
|
|
|
|
cmd.append('--session')
|
|
cmd.append(self.session_key)
|
|
|
|
proc_kwargs = {
|
|
'capture_output': True,
|
|
'encoding': 'utf-8'
|
|
}
|
|
|
|
if data:
|
|
proc_kwargs['input'] = convert_to_string(data)
|
|
|
|
result = node.run([*cmd, command, *args], **proc_kwargs)
|
|
return self._process_response(result.stdout, return_raw)
|
|
|
|
|
|
## send api command
|
|
def send(self, api, method, data=None, return_raw=False):
|
|
if not self.api_active:
|
|
raise ConnectionError('API server is not running')
|
|
|
|
kwargs = {'method': method}
|
|
|
|
url = Url.new(self.host,
|
|
port = self.get_port(),
|
|
path = api,
|
|
proto = 'http'
|
|
)
|
|
|
|
if data and method == 'GET':
|
|
url = url.replace_property('query', data)
|
|
|
|
with self._client.request(url, method, data, raise_error=True) as resp:
|
|
return self._process_response(resp.text, return_raw)
|
|
|
|
|
|
## api server
|
|
def start(self, password=None):
|
|
if self.external or self.api_active:
|
|
return
|
|
|
|
if password:
|
|
self.unlock(password)
|
|
|
|
if not self.session_key:
|
|
raise ValueError('Missing session key')
|
|
|
|
cmd = [bwjs, 'serve']
|
|
cmd.extend(['--hostname', self.host])
|
|
cmd.extend(['--port', str(self.port)])
|
|
cmd.extend(['--session', self.session_key])
|
|
|
|
self._proc = node.Popen(cmd)
|
|
|
|
while True:
|
|
try:
|
|
self.account
|
|
return
|
|
|
|
except ConnectionRefusedError:
|
|
time.sleep(0.1)
|
|
|
|
|
|
def stop(self, lock=False):
|
|
if self.external or not self.api_active:
|
|
return
|
|
|
|
self._proc.terminate()
|
|
wait_time = datetime.now()
|
|
|
|
while self._proc.poll() == None:
|
|
if wait_time + timedelta(seconds=5) >= datetime.now():
|
|
self._proc.kill()
|
|
break
|
|
|
|
if lock:
|
|
self.lock()
|
|
|
|
self._proc = None
|
|
self._account = None
|
|
self._current_port = None
|
|
|
|
## command methods
|
|
def login(self, email, password):
|
|
data = self.execute('login', email, password, key=False)
|
|
self.session_key = data.raw
|
|
return data.raw
|
|
|
|
|
|
def logout(self):
|
|
self.execute('logout')
|
|
self.session_key = None
|
|
|
|
|
|
def lock(self):
|
|
self.execute('lock')
|
|
self.session_key = None
|
|
|
|
|
|
def unlock(self, password):
|
|
data = self.execute('unlock', password)
|
|
self.session_key = data.raw
|
|
|
|
return data.raw
|
|
|
|
|
|
## api methods
|
|
def status(self):
|
|
if self.session_key:
|
|
data = self.send('status', 'GET')
|
|
|
|
else:
|
|
data = self.execute('status', key=None)
|
|
|
|
return data.template
|
|
|
|
|
|
def fetch(self, text=None, trash=False, **fields):
|
|
kwargs = {}
|
|
|
|
if (row_id := fields.get('id')):
|
|
row = self.send(f'object/item/{row_id}', 'GET')
|
|
return self.item_class(self, row)
|
|
|
|
if text:
|
|
kwargs['search'] = text
|
|
|
|
if trash:
|
|
kwargs['trash'] = trash
|
|
|
|
rows = []
|
|
data = self.send('list/object/items', 'GET', kwargs)
|
|
|
|
for row in data.data:
|
|
row = self.item_class(self, row)
|
|
|
|
if row.compare(**fields):
|
|
rows.append(row)
|
|
|
|
return self.result_class(self, rows)
|
|
|
|
|
|
def insert(self, *args, **kwargs):
|
|
self.insert_row(self.item_class.new(*args, **kwargs, storage=self))
|
|
|
|
|
|
def insert_row(self, row):
|
|
data = self.execute('create', 'item',
|
|
self.execute('encode', data=row.to_json()).data
|
|
)
|
|
|
|
return self.item_class(self, data)
|
|
|
|
|
|
## Causes the http server to return a 500 error
|
|
def insert_row_api(self, row):
|
|
data = self.send('object/item', 'POST', row._data)
|
|
return self.item_class(self, data)
|
|
|
|
|
|
def update(self, item_id, **kwargs):
|
|
row = self.fetch(id=item_id)
|
|
new_row = self.update_row(row, **kwargs)
|
|
|
|
return new_row
|
|
|
|
def update_row(self, row, **kwargs):
|
|
row.update(kwargs)
|
|
new_row = self.send(f'object/item/{row.id}', 'PUT', row.to_json())
|
|
|
|
return self.item_class(self, new_row)
|
|
|
|
|
|
def remove(self, item_id):
|
|
data = self.send(f'object/item/{item_id}', 'DELETE', return_raw=True)
|
|
|
|
if not data.get('success'):
|
|
raise BitwardenApiError(data)
|
|
|
|
return True
|
|
|
|
|
|
def purge(self, item_id):
|
|
data = self.execute('delete', 'item', item_id, '--permanent')
|
|
|
|
if not data.get('success'):
|
|
raise BitwardenApiError(data)
|
|
|
|
return True
|
|
|
|
|
|
def purge_all(self, dry_run=False):
|
|
rows = self.fetch(trash=True).all()
|
|
|
|
for row in rows:
|
|
if not dry_run:
|
|
self.purge(row.id)
|
|
|
|
return rows
|
|
|
|
|
|
def restore(self, item_id):
|
|
data = self.send(f'restore/item/{item_id}', 'POST', return_raw=True)
|
|
|
|
if not data.get('success'):
|
|
raise BitwardenApiError(data)
|
|
|
|
return True
|
|
|
|
|
|
def passgen(self, passphrase=False, **kwargs):
|
|
if passphrase:
|
|
if 'separator' in kwargs:
|
|
assert len(kwargs['separator']) == 1, 'Separator must be exactly 1 character'
|
|
|
|
data = dict(
|
|
passphrase = 'true',
|
|
words = int(kwargs.get('words', 4)),
|
|
capitalize = bool_str(kwargs.get('capitalize')),
|
|
includeNumber = bool_str(kwargs.get('numbers')),
|
|
separator = kwargs.get('separator', '-')
|
|
)
|
|
|
|
else:
|
|
data = dict(
|
|
length = int(kwargs.get('length', 20)),
|
|
uppercase = bool_str(kwargs.get('uppercase', True)),
|
|
lowercase = bool_str(kwargs.get('lowercase', True)),
|
|
number = bool_str(kwargs.get('numbers', True)),
|
|
special = bool_str(kwargs.get('special', True))
|
|
)
|
|
|
|
return self.send('generate', 'GET', data).data
|
|
|
|
|
|
def generate_passphrase(self, words=4, capitalize=False, numbers=False, separator='-'):
|
|
return self.passgen(
|
|
passphrase = True,
|
|
words = words,
|
|
capitalize = capitalize,
|
|
numbers = numbers,
|
|
separator = separator
|
|
)
|
|
|
|
|
|
def generate_password(self, length=20, uppercase=True, lowercase=True, numbers=True, special=True):
|
|
return self.passgen(
|
|
passphrase = False,
|
|
length = length,
|
|
uppercase = uppercase,
|
|
lowercase = lowercase,
|
|
numbers = numbers,
|
|
special = special
|
|
)
|
|
|
|
|
|
def template(self, name, api=False):
|
|
if api:
|
|
return self.send(f'template/{name}', 'GET').template
|
|
|
|
return self.execute('get', 'template', name).template
|
|
|
|
|
|
def get_template(self, type='login'):
|
|
if type == 'login':
|
|
template = self.template('item')
|
|
template.login = self.template('item.login')
|
|
template.login.uris = [self.template('item.login.uri')]
|
|
template.fields = [self.template('item.field')]
|
|
|
|
elif type == 'card':
|
|
template = self.template('item')
|
|
template.card = self.template('item.card')
|
|
template.fields = []
|
|
|
|
elif type == 'identity':
|
|
template = self.template('item')
|
|
template.identity = self.template('item.identity')
|
|
template.fields = []
|
|
|
|
elif type == 'note':
|
|
template = self.template('item')
|
|
template.securenote = self.template('item.securenote')
|
|
template.fields = []
|
|
|
|
elif type == 'folder':
|
|
template = self.template('folder')
|
|
|
|
elif type == 'collection':
|
|
template = self.template('collection')
|
|
|
|
else:
|
|
raise ValueError(f'Not a valid template: {type}')
|
|
|
|
return template
|
|
|
|
|
|
def sync(self):
|
|
data = self.send('sync', 'POST', return_raw=True)
|
|
return data.success
|