def run(self): while True: self.server.start_flag.wait() if self.server.kill_flag.is_set(): break try: self.cache_timer.cancel() except AttributeError: pass self._set_timer() res = dmenu_run(self.entries, self.folders, self.collections, self.session, self.prev_entry) if res == Run.LOCK: try: self.server.kill_flag.set() except (EOFError, IOError): return if res == Run.RELOAD: self.entries, self.folders, self.collections, self.orgs = \ bwcli.get_entries(self.session) if not all(i for i in (self.entries, self.folders, self.collections, self.orgs) if i is False): dmenu_err("Error loading entries. See logs.") if str(res) not in repr(Run.__members__): self.prev_entry = res or self.prev_entry if self.server.cache_time_expired.is_set(): self.server.kill_flag.set() if self.server.kill_flag.is_set(): break self.server.start_flag.clear()
def type_entry(entry): """Pick which library to use to type strings Defaults to pynput Args: entry - dict """ # Don't autotype anything except for login and cards - for now TODO if entry['type'] not in (1, 3): dmenu_err("Autotype currently disabled for this type of entry") return sequence = autotype_seq(entry) if sequence == 'False': dmenu_err("Autotype disabled for this entry") return if not sequence or sequence == 'None': sequence = SEQUENCE if entry['type'] == 3: sequence = "{CARDNUM}" tokens = tokenize_autotype(sequence) library = 'pynput' if CONF.has_option('vault', 'type_library'): library = CONF.get('vault', 'type_library') if library == 'xdotool': type_entry_xdotool(entry, tokens) elif library == 'ydotool': type_entry_ydotool(entry, tokens) else: type_entry_pynput(entry, tokens)
def get_auth(): """Generate and save port and authkey to ~/.cache/.bwm-auth Returns: int port, bytestring authkey """ auth = bwm.configparser.ConfigParser() if not exists(bwm.AUTH_FILE): fdr = os.open(bwm.AUTH_FILE, os.O_WRONLY | os.O_CREAT, 0o600) with open(fdr, 'w', encoding=bwm.ENC) as a_file: auth.set('DEFAULT', 'port', str(find_free_port())) auth.set('DEFAULT', 'authkey', random_str()) auth.write(a_file) try: auth.read(bwm.AUTH_FILE) port = auth.get('DEFAULT', 'port') authkey = auth.get('DEFAULT', 'authkey').encode() except (bwm.configparser.NoOptionError, bwm.configparser.MissingSectionHeaderError, bwm.configparser.ParsingError, multiprocessing.context.AuthenticationError): os.remove(bwm.AUTH_FILE) dmenu_err("Cache file was corrupted. Stopping all instances. Please try again") call(["pkill", "bwm"]) # Kill all prior instances as well return None, None return int(port), authkey
def type_entry_ydotool(entry, tokens): """Auto-type entry entry using ydotool """ enter_idx = True for token, special in tokens: if special: cmd = token_command(token) if callable(cmd): cmd() # pylint: disable=not-callable elif token in PLACEHOLDER_AUTOTYPE_TOKENS: to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) if to_type: call(['ydotool', 'type', to_type]) elif token in STRING_AUTOTYPE_TOKENS: to_type = STRING_AUTOTYPE_TOKENS[token] call(['ydotool', 'type', to_type]) elif token in YDOTOOL_AUTOTYPE_TOKENS: cmd = ['ydotool'] + YDOTOOL_AUTOTYPE_TOKENS[token] call(cmd) # Add extra {ENTER} key tap for first instance of {ENTER}. It # doesn't get recognized for some reason. if enter_idx is True and token in ("{ENTER}", "~"): cmd = ['ydotool'] + YDOTOOL_AUTOTYPE_TOKENS[token] call(cmd) enter_idx = False else: dmenu_err(f"Unsupported auto-type token (ydotool): {token}") return else: call(['ydotool', 'type', token])
def get_password_chars(): """Get characters to use for password generation from defaults, config file and user input. Returns: Dict {preset_name_1: {char_set_1: string, char_set_2: string}, preset_name_2: ....} """ chars = {"upper": string.ascii_uppercase, "lower": string.ascii_lowercase, "digits": string.digits, "punctuation": string.punctuation} presets = {} presets["Letters+Digits+Punctuation"] = chars presets["Letters+Digits"] = {k: chars[k] for k in ("upper", "lower", "digits")} presets["Letters"] = {k: chars[k] for k in ("upper", "lower")} presets["Digits"] = {k: chars[k] for k in ("digits",)} if bwm.CONF.has_section('password_chars'): pw_chars = dict(bwm.CONF.items('password_chars')) chars.update(pw_chars) for key, val in pw_chars.items(): presets[key.title()] = {k: chars[k] for k in (key,)} if bwm.CONF.has_section('password_char_presets'): if bwm.CONF.options('password_char_presets'): presets = {} for name, val in bwm.CONF.items('password_char_presets'): try: presets[name.title()] = {k: chars[k] for k in shlex.split(val)} except KeyError: dmenu_err(f"Error: Unknown value in preset {name}. Ignoring.") continue inp = "\n".join(presets) char_sel = dmenu_select(len(presets), "Pick character set(s) to use", inp=inp) # This dictionary return also handles Rofi multiple select return {k: presets[k] for k in char_sel.split('\n')} if char_sel else False
def dmenu_sync(session): """Call vault sync option (called from dmenu_run) Args: session (bytes) """ res = bwcli.sync(session) if res is False: dmenu_err("Sync error. Check logs.")
def delete_entry(entry, entries, session): """Delete an entry Args: entry - dict entries - list of dicts session - bytes """ inp = "NO\nYes - confirm delete\n" delete = dmenu_select(2, f"Confirm delete of {entry['name']}", inp=inp) if delete != "Yes - confirm delete": return res = bwcli.delete_entry(entry, session) if res is False: dmenu_err("Item not deleted. Check logs.") return del entries[entries.index(res)]
def rename_folder(folders, session): """Rename folder Args: folders - dict {'name': folder dict, ...} """ folder = select_folder(folders, prompt="Select folder to rename") if folder is False or folder['name'] == "No Folder": return name = dmenu_select(1, "New folder name", inp=basename(folder['name'])) if not name: return new = join(dirname(folder['name']), name) folder = bwcli.move_folder(folder, new, session) if folder is False: dmenu_err("Folder not renamed. Check logs.") return folders[folder['id']] = folder
def type_text(data): """Type the given text data """ library = 'pynput' if CONF.has_option('vault', 'type_library'): library = CONF.get('vault', 'type_library') if library == 'xdotool': call(['xdotool', 'type', data]) elif library == 'ydotool': call(['ydotool', 'type', data]) else: kbd = keyboard.Controller() try: kbd.type(data) except kbd.InvalidCharacterException: dmenu_err("Unable to type string...bad character.\n" "Try setting `type_library = xdotool` in config.ini")
def get_initial_vault(): """Ask for initial server URL and email if not entered in config file """ url = dmenu_select(0, "Enter server URL.", "https://vault.bitwarden.com") if not url: dmenu_err("No URL entered. Try again.") return False email = dmenu_select(0, "Enter login email address.") twofa = {'None': '', 'TOTP': 0, 'Email': 1, 'Yubikey': 3} method = dmenu_select(len(twofa), "Select Two Factor Auth type.", "\n".join(twofa)) with open(bwm.CONF_FILE, 'w', encoding=bwm.ENC) as conf_file: bwm.CONF.set('vault', 'server_1', url) if email: bwm.CONF.set('vault', 'email_1', email) if method: bwm.CONF.set('vault', 'twofactor_1', str(twofa[method])) bwm.CONF.write(conf_file) return (url, email, '', twofa[method])
def rename_collection(collections, session): """Rename collection Args: collections - dict {'name': collection dict, ...} """ collection = select_collection(collections, session, prompt="Select collection to rename") if not collection: return collection = next(iter(collection.values())) name = dmenu_select(1, "New collection name", inp=basename(collection['name'])) if not name: return new = join(dirname(collection['name']), name) res = bwcli.move_collection(collection, new, session) if res is False: dmenu_err("Collection not deleted. Check logs.") return collections[collection['id']] = res
def edit_password(entry): # pylint: disable=too-many-return-statements """Edit password Args: entry dict Returns: entry dict """ sel = entry['login']['password'] pw_orig = sel + "\n" if sel is not None else "\n" inputs = ["Generate password", "Manually enter password"] if entry['login']['password']: inputs.append("Type existing password") pw_choice = dmenu_select(len(inputs), "Password Options", inp="\n".join(inputs)) if pw_choice == "Manually enter password": sel = dmenu_select(1, "Password", inp=pw_orig) sel_check = dmenu_select(1, "Verify password") if not sel_check or sel_check != sel: dmenu_err("Passwords do not match. No changes made.") return False elif pw_choice == "Generate password": inp = "20\n" length = dmenu_select(1, "Password Length?", inp=inp) if not length: return False try: length = int(length) except ValueError: length = 20 chars = get_password_chars() if chars is False: return False sel = gen_passwd(chars, length) if sel is False: dmenu_err("Number of char groups desired is more than requested pw length") return False elif pw_choice == "Type existing password": type_text(entry['login']['password']) return False else: return False entry['login']['password'] = sel return entry
def delete_folder(folders, session): """Delete a folder Args: folder - folder dict obj session - bytes """ folder = select_folder(folders, prompt="Delete Folder:") if not folder or folder['name'] == "No Folder": return inp = "NO\nYes - confirm delete\n" delete = dmenu_select(2, "Confirm delete", inp=inp) if delete != "Yes - confirm delete": return res = bwcli.delete_folder(folder, session) if res is False: dmenu_err("Folder not deleted. Check logs.") return del folders[folder['id']]
def delete_collection(collections, session): """Delete a collection Args: collections- dict of all collection objects session - bytes """ collection = select_collection(collections, session, prompt="Delete collection:") if not collection: return collection = next(iter(collection.values())) inp = "NO\nYes - confirm delete\n" delete = dmenu_select(2, f"Confirm delete of {collection['name']}", inp=inp) if delete != "Yes - confirm delete": return res = bwcli.delete_collection(collection, session) if res is False: dmenu_err("Collection not deleted. Check logs.") return del collections[collection['id']]
def move_folder(folders, session): """Move folder Args: folders - dict {'name': folder dict, ...} """ folder = select_folder(folders, prompt="Select folder to move") if folder is False or folder['name'] == "No Folder": return destfolder = select_folder(folders, prompt="Select destination folder. 'No Folder' is root.") if destfolder is False: return dname = "" if destfolder['name'] != "No Folder": dname = destfolder['name'] folder = bwcli.move_folder(folder, join(dname, basename(folder['name'])), session) if folder is False: dmenu_err("Folder not added. Check logs.") return folders[folder['id']] = folder
def create_folder(folders, session): """Create new folder Args: folders - dict of folder objects """ parentfolder = select_folder(folders, prompt="Select parent folder") if parentfolder is False: return pfname = parentfolder['name'] if pfname == "No Folder": pfname = "" name = dmenu_select(1, "Folder name") if not name: return name = join(pfname, name) folder = bwcli.add_folder(name, session) if folder is False: dmenu_err("Folder not added. Check logs.") return folders[folder['id']] = folder
def edit_notes(note): """Use $EDITOR (or 'vim' if not set) to edit the notes entry In configuration file: Set 'gui_editor' for things like emacs, gvim, leafpad Set 'editor' for vim, emacs -nw, nano unless $EDITOR is defined Set 'terminal' if using a non-gui editor Args: note - string Returns: note - string """ if bwm.CONF.has_option("vault", "gui_editor"): editor = bwm.CONF.get("vault", "gui_editor") editor = shlex.split(editor) else: if bwm.CONF.has_option("vault", "editor"): editor = bwm.CONF.get("vault", "editor") else: editor = os.environ.get('EDITOR', 'vim') if bwm.CONF.has_option("vault", "terminal"): terminal = bwm.CONF.get("vault", "terminal") else: terminal = "xterm" terminal = shlex.split(terminal) editor = shlex.split(editor) editor = terminal + ["-e"] + editor note = b'' if note is None else note.encode(bwm.ENC) with tempfile.NamedTemporaryFile(suffix=".tmp") as fname: fname.write(note) fname.flush() editor.append(fname.name) try: call(editor) fname.seek(0) note = fname.read() except FileNotFoundError: dmenu_err("Terminal not found. Please update config.ini.") note = '' if not note else note.decode(bwm.ENC) return note
def tokenize_autotype(autotype): """Process the autotype sequence Args: autotype - string Returns: tokens - generator ((token, if_special_char T/F), ...) """ while autotype: opening_idx = -1 for char in "{+^%~@": idx = autotype.find(char) if idx != -1 and (opening_idx == -1 or idx < opening_idx): opening_idx = idx if opening_idx == -1: # found the end of the string without further opening braces or # other characters yield autotype, False return if opening_idx > 0: yield autotype[:opening_idx], False if autotype[opening_idx] in "+^%~@": yield autotype[opening_idx], True autotype = autotype[opening_idx + 1:] continue closing_idx = autotype.find('}') if closing_idx == -1: dmenu_err("Unable to find matching right brace (}) while" + f"tokenizing auto-type string: {autotype}\n") return if closing_idx == opening_idx + 1 and closing_idx + 1 < len(autotype) \ and autotype[closing_idx + 1] == '}': yield "{}}", True autotype = autotype[closing_idx + 2:] continue yield autotype[opening_idx:closing_idx + 1], True autotype = autotype[closing_idx + 1:]
def type_entry_pynput(entry, tokens): # pylint: disable=too-many-branches """Use pynput to auto-type the selected entry """ kbd = keyboard.Controller() enter_idx = True for token, special in tokens: if special: cmd = token_command(token) if callable(cmd): cmd() # pylint: disable=not-callable elif token in PLACEHOLDER_AUTOTYPE_TOKENS: to_type = PLACEHOLDER_AUTOTYPE_TOKENS[token](entry) if to_type: try: kbd.type(to_type) except kbd.InvalidCharacterException: dmenu_err("Unable to type string...bad character.\n" "Try setting `type_library = xdotool` in config.ini") return elif token in STRING_AUTOTYPE_TOKENS: to_type = STRING_AUTOTYPE_TOKENS[token] try: kbd.type(to_type) except kbd.InvalidCharacterException: dmenu_err("Unable to type string...bad character.\n" "Try setting `type_library = xdotool` in config.ini") return elif token in PYNPUT_AUTOTYPE_TOKENS: to_tap = PYNPUT_AUTOTYPE_TOKENS[token] kbd.tap(to_tap) # Add extra {ENTER} key tap for first instance of {ENTER}. It # doesn't get recognized for some reason. if enter_idx is True and token in ("{ENTER}", "~"): kbd.tap(to_tap) enter_idx = False else: dmenu_err(f"Unsupported auto-type token (pynput): {token}") return else: try: kbd.type(token) except kbd.InvalidCharacterException: dmenu_err("Unable to type string...bad character.\n" "Try setting `type_library = xdotool` in config.ini") return
def move_collection(collections, session): """Move collection Args: collections - dict {'name': collection dict, ...} """ collection = select_collection(collections, session, prompt="Select collection to move") if not collection: return collection = next(iter(collection.values())) destcollection = select_collection(collections, session, prompt="Select destination collection " "(Esc to move to root directory)") if not destcollection: destcollection = {'name': ""} else: destcollection = next(iter(destcollection.values())) res = bwcli.move_collection(collection, join(destcollection['name'], basename(collection['name'])), session) if res is False: dmenu_err("Collection not moved. Check logs.") return collections[collection['id']] = res
CONF.add_section('dmenu_passphrase') CONF.set('dmenu_passphrase', 'obscure', 'True') CONF.set('dmenu_passphrase', 'obscure_color', '#222222') CONF.add_section('vault') CONF.set('vault', 'server_1', '') CONF.set('vault', 'email_1', '') CONF.set('vault', 'twofactor_1', '') CONF.set('vault', 'session_timeout_min ', str(SESSION_TIMEOUT_DEFAULT_MIN)) CONF.set('vault', 'autotype_default', SEQUENCE) CONF.write(conf_file) CONF = configparser.ConfigParser() try: CONF.read(CONF_FILE) except configparser.ParsingError as err: dmenu_err(f"Config file error: {err}") sys.exit() if CONF.has_option('dmenu', 'dmenu_command'): command = shlex.split(CONF.get('dmenu', 'dmenu_command')) if "-l" in command: MAX_LEN = int(command[command.index("-l") + 1]) else: MAX_LEN = 24 if CONF.has_option("vault", "session_timeout_min"): SESSION_TIMEOUT_MIN = int(CONF.get("vault", "session_timeout_min")) else: SESSION_TIMEOUT_MIN = SESSION_TIMEOUT_DEFAULT_MIN if CONF.has_option('vault', 'autotype_default'): SEQUENCE = CONF.get("vault", "autotype_default") if CONF.has_option("vault", "type_library"): if CONF.get("vault", "type_library") == "xdotool":
def edit_entry(entry, entries, folders, collections, session): # pylint: disable=too-many-branches,too-many-statements,too-many-locals """Edit title, username, password, url, notes and autotype sequence for an entry. Args: entry - selected Entry dict entries - list of dicts folders - dict of dicts {'id': {xxx,yyy}, ... } collections - dict of dicts {'id': {xxx,yyy}, ... } session - bytes Returns: None or entry (Item) """ item = deepcopy(entry) update_colls = "NO" while True: fields = [str("Name: {}").format(item['name']), str("Folder: {}").format(obj_name(folders, item['folderId'])), str("Collections: {}").format(", ".join(obj_name(collections, i) for i in item['collectionIds'])), str("Username: {}").format(item['login']['username']), str("Password: **********") if item['login']['password'] else "Password: None", str("Url: {}").format(item['login']['url']), str("Autotype: {}").format(autotype_seq(item)), "Notes: <Enter to Edit>" if item['notes'] else "Notes: None", "Delete entry", "Save entry"] inp = "\n".join(fields) sel = dmenu_select(len(fields), inp=inp) if sel == 'Delete entry': delete_entry(entry, entries, session) return None if sel == 'Save entry': if not item.get('id'): res = bwcli.add_entry(item, session) if res is False: dmenu_err("Entry not added. Check logs.") return None entries.append(bwcli.Item(res)) else: res = bwcli.edit_entry(item, session, update_colls) if res is False: dmenu_err("Error saving entry. Changes not saved.") continue entries[entries.index(entry)] = bwcli.Item(res) return bwcli.Item(res) try: field, sel = sel.split(": ", 1) except (ValueError, TypeError): return entry field = field.lower() if field == 'password': item = edit_password(item) or item continue if field == 'folder': folder = select_folder(folders) if folder is not False: item['folderId'] = folder['id'] continue if field == 'collections': orig = item['collectionIds'] coll_list = [collections[i] for i in collections if i in item['collectionIds']] collection = select_collection(collections, session, coll_list=coll_list) item['collectionIds'] = [*collection] if collection: item['organizationId'] = next(iter(collection.values()))['organizationId'] if item['collectionIds'] and item['collectionIds'] != orig and orig: update_colls = "YES" elif item['collectionIds'] != orig and not orig: update_colls = "MOVE" elif not item['collectionIds'] and orig: update_colls = "REMOVE" continue if field == 'notes': item['notes'] = edit_notes(item['notes']) continue if field in ('username', 'url'): edit = item['login'][field] + \ "\n" if item['login'][field] is not None else "\n" elif field == 'autotype': edit = item['fields'][autotype_index(item)]['value'] + \ "\n" if item['fields'][autotype_index(item)]['value'] is not None else "\n" else: edit = item[field] + "\n" if item[field] is not None else "\n" sel = dmenu_select(1, f"{field.capitalize()}", inp=edit) if sel: if field in ('username', 'url'): item['login'][field] = sel if field == 'url': item['login']['uris'] = [{'match': None, 'uri': sel}] elif field == 'autotype': item['fields'][autotype_index(item)]['value'] = sel else: item[field] = sel
def get_vault(): # pylint: disable=too-many-return-statements,too-many-locals,too-many-branches """Read vault login parameters from config or ask for user input. Returns: Session - bytes None on error opening/reading vault """ args = bwm.CONF.items('vault') args_dict = dict(args) servers = [i for i in args_dict if i.startswith('server')] vaults = [] for srv in servers: idx = srv.rsplit('_', 1)[-1] email = args_dict.get(f'email_{idx}', "") passw = args_dict.get(f'password_{idx}', "") twofactor = args_dict.get(f'twofactor_{idx}', None) if not args_dict[srv] or not email: continue try: cmd = args_dict[f'password_cmd_{idx}'] res = subprocess.run(shlex.split(cmd), check=False, capture_output=True, encoding=bwm.ENC) if res.stderr: dmenu_err(f"Password command error: {res.stderr}") sys.exit() else: passw = res.stdout.rstrip('\n') if res.stdout else passw except KeyError: pass vaults.append((args_dict[srv], email, passw, twofactor)) if not vaults or (not vaults[0][0] or not vaults[0][1]): res = get_initial_vault() if res: vaults.insert(0, res) else: return None if len(vaults) > 1: inp = "\n".join(i[0] for i in vaults) sel = dmenu_select(len(vaults), "Select Vault", inp=inp) vaults = [i for i in vaults if i[0] == sel] if not sel or not vaults: return None url, email, passw, twofactor = vaults[0] status = bwcli.status() if status is False: return None if not passw: passw = get_passphrase() if not passw: return None if status['serverUrl'] != url: res = bwcli.set_server(url) if res is False: return None if status['userEmail'] != email or status['status'] == 'unauthenticated': code = get_passphrase(True) if twofactor else "" session, err = bwcli.login(email, passw, twofactor, code) elif status['status'].endswith('locked'): session, err = bwcli.unlock(passw) if session is False: dmenu_err(err) return None return session