class BwPyro: """ Start and control the execution of the program """ def __init__(self): self._rofi = None self._session = None self._vault = None self._clipboard = None self._autotype = None self._notify = None self._config = None self._focus = None self._args = parse_arguments() self._logger = ProjectLogger(self._args.verbose, not self._args.no_logging).get_logger() def start(self): """Start the execution of the program""" if self._args.version: print(f"{NAME} v{VERSION}") sys.exit() elif self._args.lock: self.__lock() elif self._args.dump_config: self.__dump_config() else: self.__launch_ui() def __dump_config(self): try: self._logger.setLevel(logging.ERROR) self._config = ConfigLoader(self._args) dump = self._config.dump() print(dump) except ConfigException: self._logger.exception("Failed to dump config") def __lock(self): try: self._logger.info("Locking vault and deleting session") self._session = Session() self._session.lock() except SessionException: self._logger.exception("Failed to lock session") self._rofi = Rofi([], None, None) self._rofi.show_error("Failed to lock and delete session") def __unlock(self, force=False): self._logger.info("Unlocking bitwarden vault") if force or not self._session.has_key(): pwd = self._rofi.get_password() if pwd is not None: self._session.unlock(pwd) else: self._logger.info("Unlocking aborted") sys.exit(0) k = self._session.get_key() self._vault.set_key(k) def __show_items(self, prompt): items = self._vault.get_items() # Convert items to \n separated strings formatted = ItemFormatter.unique_format(items) selected_name, event = self._rofi.show_items(formatted, prompt) self._logger.debug("User selected login: %s", selected_name) # Rofi dialog has been closed if selected_name is None: self._logger.debug("Item selection has been aborted") return (None, None) # Make sure that the group item isn't a single item where # the deduplication marker coincides if selected_name.startswith(ItemFormatter.DEDUP_MARKER) and \ len(self._vault.get_by_name(selected_name)) == 0: self._logger.debug("User selected item group") group_name = selected_name[len(ItemFormatter.DEDUP_MARKER):] selected_items = self._vault.get_by_name(group_name) if isinstance(event, ItemActions): event = WindowActions.GROUP return (event, selected_items) # A single item has been selected self._logger.debug("User selected single item") selected_item = self._vault.get_by_name(selected_name) return (event, selected_item) def __show_indexed_items(self, prompt, items=None, fields=None, ignore=None): if items is None: items = self._vault.get_items() converter = create_converter(fields, ignore) indexed, formatted = ItemFormatter.group_format(items, converter) selected_name, event = self._rofi.show_items(formatted, prompt) # Rofi has been closed if selected_name is None: self._logger.debug("Group item selection has been aborted") return (None, None) # An item has been selected regex = r"^#([0-9]+): .*" match = re.search(regex, selected_name) selected_index = int(match.group(1)) - 1 selected_item = indexed[selected_index] return (event, selected_item) def __show_folders(self, prompt): items = self._vault.get_folders() formatted = ItemFormatter.unique_format(items) selected_name, event = self._rofi.show_items(formatted, prompt) self._logger.info("User selected folder: %s", selected_name) if selected_name is None: self._logger.debug("Folder selection has been aborted") return (None, None) folder = [i for i in items if i['name'] == selected_name][0] if folder['name'] == 'No Folder': self._logger.debug("Clearing vault folder filter") self._vault.set_filter(None) else: self._vault.set_filter(folder) if isinstance(event, ItemActions): event = WindowActions.NAMES return (event, None) def __load_items(self, use_cache=True): try: # First attempt at loading items self._vault.load_items(use_cache) except VaultException: self._logger.warning("First attempt at loading vault items failed") self.__unlock(force=True) self._vault.load_items(use_cache) def __set_keybinds(self): keybinds = { 'type_password': ItemActions.PASSWORD, 'type_all': ItemActions.ALL, 'copy_totp': ItemActions.TOTP, 'mode_uris': WindowActions.URIS, 'mode_names': WindowActions.NAMES, 'mode_logins': WindowActions.LOGINS, 'mode_folders': WindowActions.FOLDERS, 'sync': WindowActions.SYNC } for name, action in keybinds.items(): self._rofi.add_keybind( self._config.get(f'keyboard.{name}.key'), action, self._config.get(f'keyboard.{name}.hint'), self._config.get(f'keyboard.{name}.show'), ) def __init_ui(self): try: self._config = ConfigLoader(self._args) self._session = Session(self._config.get_int('security.timeout')) RofiArgs = namedtuple( 'RofiArgs', 'main_window_args password_window_args additional_args') rofi_args = RofiArgs( shlex.split(self._args.main_window_rofi_args), shlex.split(self._args.password_window_rofi_args), self._args.rofi_args) self._rofi = Rofi(rofi_args, self._config.get_itemaction('keyboard.enter'), self._config.get_boolean('interface.hide_mesg')) self._clipboard = Clipboard(self._config.get_int('security.clear')) self._autotype = AutoType() self._vault = Vault(self._config.get_int('security.cache')) self._notify = Notify() self._focus = Focus( self._config.get_boolean('autotype.select_window'), self._config.get('autotype.slop_args')) self.__set_keybinds() except (ClipboardException, AutoTypeException, CacheException, SessionException, VaultException, ConfigException): self._logger.exception("Failed to initialise application") sys.exit(1) def __display_windows(self): action = self._config.get_windowaction('interface.window_mode') while action is not None and isinstance(action, WindowActions): self._logger.info("Switch window mode to %s", action) prompt = 'Bitwarden' if self._vault.has_filter(): prompt = self._vault.get_filter()['name'] # A group of items has been selected if action == WindowActions.NAMES: action, item = self.__show_items(prompt=prompt) elif action == WindowActions.GROUP: action, item = self.__show_indexed_items( prompt=item[0]['name'], items=item, fields=['login.username']) elif action == WindowActions.URIS: action, item = self.__show_indexed_items( prompt=prompt, fields=['login.uris.uri'], ignore=['http://', 'https://', 'None']) elif action == WindowActions.LOGINS: action, item = self.__show_indexed_items( prompt=prompt, fields=['name', 'login.username']) elif action == WindowActions.SYNC: self._vault.sync() self.__load_items(use_cache=False) action, item = self.__show_items(prompt=prompt) elif action == WindowActions.FOLDERS: action, item = self.__show_folders(prompt='Folders') return action, item def __delay_type(self): # Delay typing, allowing correct window to be focused if self._focus.is_enabled(): okay = self._focus.select_window() if not okay: self._logger.warning("Focus has been cancelled") sys.exit(0) else: start_delay = self._config.get_int('autotype.start_delay') focus_notification = self._config.get_boolean( 'autotype.delay_notification') if focus_notification: self._notify.send( message= f"Waiting {start_delay} second(s) for window to refocus", timeout=start_delay * 1000 # Convert to ms ) sleep(start_delay) def __execute_action(self, action, item): if action == ItemActions.COPY: self._logger.info("Copying password to clipboard") # Get item with password item = self._vault.get_item_full(item) self._notify.send( message="Login password copied to clipboard", timeout=self._clipboard.clear * 1000 # convert to ms ) self._clipboard.set(item['login']['password']) elif action == ItemActions.ALL: self._logger.info("Auto tying username and password") # Get item with password item = self._vault.get_item_full(item) self.__delay_type() self._notify.send(message="Auto typing username and password") tab_delay = self._config.get_float('autotype.tab_delay') self._autotype.string(item['login']['username']) sleep(tab_delay) self._autotype.key('Tab') sleep(tab_delay) self._autotype.string(item['login']['password']) elif action == ItemActions.PASSWORD: self._logger.info("Auto typing password") # Get item with password item = self._vault.get_item_full(item) self.__delay_type() self._notify.send(message="Auto typing password") self._autotype.string(item['login']['password']) elif action == ItemActions.TOTP: self._logger.info("Copying TOTP to clipboard") totp = self._vault.get_item_topt(item) self._notify.send( message="TOTP is copied to the clipboard", timeout=self._clipboard.clear * 1000 # convert to ms ) self._clipboard.set(totp) else: self._logger.error("Unknown action received: %s", action) def __launch_ui(self): self._logger.info("Application has been launched") self.__init_ui() try: self.__unlock() self.__load_items() action, item = self.__display_windows() # Selection has been aborted if action is None: self._logger.info("Exiting. Login selection has been aborted") sys.exit(0) self.__execute_action(action, item) except (AutoTypeException, ClipboardException, SessionException, VaultException, FocusException) as exc: self._logger.exception("Application has received a critical error") self._rofi.show_error(f"An error has occurred. {exc}")
class Rofi: """Start and retrieve results from Rofi windows""" def __init__(self, args, enter_event, hide_mesg): self._logger = ProjectLogger().get_logger() self._keybinds = {} self._main_window_args = args.main_window_args + args.additional_args[ 1:] self._password_window_args = args.password_window_args + args.additional_args[ 1:] self._enter_event = enter_event self._hide_mesg = hide_mesg self._keybinds_code = 10 if len(self._main_window_args) > 0: self._logger.debug("Setting rofi arguments for main window: %s", self._main_window_args) if len(self._password_window_args) > 0: self._logger.debug( "Setting rofi arguments for password window: %s", self._password_window_args) def __extend_command(self, command, args): if len(args) > 0: command.extend(args) if not self._hide_mesg: mesg = [] for keybind in self._keybinds.values(): if keybind.message is not None and keybind.show: mesg.append(f"<b>{keybind.key}</b>: {keybind.message}") if len(mesg) > 0: command.extend(["-mesg", ", ".join(mesg)]) for code, keybind in self._keybinds.items(): command.extend([f"-kb-custom-{code - 9}", keybind.key]) return command def add_keybind(self, key, event, message, show): """Create a keybind object and add store it in memory""" if self._keybinds_code == 28: raise KeybindException( "The maximum number of keybinds has been reached") self._keybinds[self._keybinds_code] = Keybind(key, event, message, show) self._keybinds_code += 1 def get_password(self): """Launch a window requesting a password""" try: self._logger.info("Launching rofi password prompt") cmd = [ "rofi", "-dmenu", "-p", "Master Password", "-password", "-lines", "0" ] if len(self._password_window_args) > 0: cmd.extend(self._password_window_args) proc = sp.run(cmd, check=True, capture_output=True) return proc.stdout.decode("utf-8").strip() except CalledProcessError: self._logger.info("Password prompt has been closed") return None def show_error(self, message): """Launch a window showing an error message""" try: self._logger.info("Showing Rofi error") cmd = ["rofi", "-e", f"ERROR! {message}"] if len(self._main_window_args) > 0: cmd.extend(self._main_window_args) sp.run(cmd, capture_output=True, check=True) except CalledProcessError: raise RofiException("Rofi failed to display error message") def show_items(self, items, prompt='Bitwarden'): """Show a list of items and return the selectem item and action""" try: self._logger.info("Launching rofi login select") echo_cmd = ["echo", items] rofi_cmd = self.__extend_command( ["rofi", "-dmenu", "-p", prompt, "-i", "-no-custom"], self._main_window_args) echo_proc = sp.Popen(echo_cmd, stdout=sp.PIPE) rofi_proc = sp.run(rofi_cmd, stdin=echo_proc.stdout, stdout=sp.PIPE, check=False) return_code = rofi_proc.returncode selected = rofi_proc.stdout.decode("utf-8").strip() # Clean exit if return_code == 1: return None, None # Selected item by enter if return_code == 0: return selected, self._enter_event # Selected item using custom keybind if return_code in self._keybinds: return selected, self._keybinds.get(return_code).event self._logger.warning("Unknown return code has been received: %s", return_code) return None, None except CalledProcessError: self._logger.info("Login select has been closed") return None
class Cache: """Read and write item data to cache files""" _cache_dir = f'~/.cache/{NAME}/' _items_file = 'items.json' _meta_file = 'items.metadata' def __init__(self, expiry): self._path = None self._meta = None self._logger = ProjectLogger().get_logger() self._expiry = expiry # Negative values disable cache self.__items_path = lambda: os.path.join(self._path, self._items_file) self.__meta_path = lambda: os.path.join(self._path, self._meta_file) self.__init_meta() def __init_meta(self): if not self.should_cache(): self._logger.info("Disabling caching of items") return try: self._path = os.path.expanduser(self._cache_dir) if not os.path.isdir(self._path): os.makedirs(self._path) else: mpath = self.__meta_path() ipath = self.__items_path() # Both items and metadata file need to be present, # however, their validity is not checked if os.path.isfile(ipath) and os.path.isfile(mpath): with open(mpath, 'r') as file: meta_json = json.load(file) self._meta = CacheMetadata.create(meta_json) self._logger.debug("Initialised meta data from %s", mpath) except IOError: raise CacheException("Failed to initialise cache metadata") def should_cache(self): """ Returns true if expiry is a positive number """ return self._expiry > 0 def __cache_age(self): """Returns the age in days of the saved cache""" if self._meta is None: raise CacheException("Cache metadata has not been initialised") seconds = (time.time() - self._meta.time_created) days = seconds / 86_400 return days def get(self): """Return a collection of cached items""" try: ipath = self.__items_path() self._logger.debug("Reading cached items from %s", ipath) with open(ipath, 'r') as file: items = json.load(file) return items except IOError: raise CacheException(f"Failed to write cache data to {self._path}") def save(self, items): """Sanitise and save a collection of items to a cache files""" try: self._logger.debug("Writing cache to %s", self._path) self._meta = CacheMetadata(time.time(), len(items)) meta_path = os.path.join(self._path, self._meta_file) with open(meta_path, 'w') as file: json.dump(self._meta.to_dict(), file) # Chmod to 600 os.chmod(meta_path, stat.S_IWRITE | stat.S_IREAD) # Sanitize cache by removing sensitive data for item in items: if item.get('login'): if item.get('login').get('password') is not None: item['login']['password'] = None if item.get('login').get('totp') is not None: item['login']['totp'] = None item_path = os.path.join(self._path, self._items_file) with open(item_path, 'w') as file: json.dump(items, file) # Chmod to 600 os.chmod(item_path, stat.S_IWRITE | stat.S_IREAD) except IOError: raise CacheException(f"Failed to write cache data to {self._path}") def has_items(self): """Returns true if cache is enabled, not expired and contains items""" return self._expiry > 0 \ and self._meta is not None \ and self._meta.count > 0 \ and self.__cache_age() < self._expiry
class ConfigLoader: """Single source of truth for config data, merging default, file and args""" _default_values = { 'security': { 'timeout': 900, # Session expiry in seconds 'clear': 5, # Clipboard persistency in seconds 'cache': 7 }, 'keyboard': { 'enter': str(ItemActions.COPY), 'type_password': { 'key': 'Alt+1', 'hint': 'Type password', 'show': True }, 'type_all': { 'key': 'Alt+2', 'hint': 'Type all', 'show': True }, 'mode_uris': { 'key': 'Alt+u', 'hint': 'Show URIs', 'show': True }, 'mode_names': { 'key': 'Alt+n', 'hint': 'Show names', 'show': True }, 'mode_logins': { 'key': 'Alt+l', 'hint': 'Show logins', 'show': True }, 'mode_folders': { 'key': 'Alt+c', 'hint': 'Show folders', 'show': True }, 'copy_totp': { 'key': 'Alt+t', 'hint': 'totp', 'show': True }, 'sync': { 'key': 'Alt+r', 'hint': 'sync', 'show': True } }, 'autotype': { 'select_window': False, 'slop_args': '-l -c 0.3,0.4,0.6,0.4 --nodecorations', 'start_delay': 1, 'tab_delay': 0.2, 'delay_notification': True }, 'interface': { 'hide_mesg': False, 'window_mode': str(WindowActions.NAMES), } } _default_path = f'~/.config/{NAME}/config' def __init__(self, args): self._logger = ProjectLogger().get_logger() self._config = None self.__init_config(args) self.__init_converters() def __init_converters(self): self.add_converter('int', int) self.add_converter('float', float) self.add_converter('boolean', lambda t: str(t).lower() == "true") self.add_converter('windowaction', lambda a: WindowActions[a.upper()]) self.add_converter('itemaction', lambda a: ItemActions[a.upper()]) def __init_config(self, args): # Load default values from dict self._config = self._default_values # Command line arguments ovewrite default values and those # set by config file if not args.no_config: self.__from_file(args.config) else: self._logger.info("Preventing config file from loading") self.__from_args(args) def __from_args(self, args): if args.timeout is not None: self.set('security.timeout', args.timeout) if args.clear is not None: self.set('security.clear', args.clear) if args.enter is not None: self.set('keyboard.enter', args.enter) if args.window_mode is not None: self.set('interface.window_mode', args.window_mode) if args.cache is not None: self.set('security.cache', args.cache) if args.select_window: self.set('autotype.select_window', args.select_window) if args.hide_mesg: self.set('interface.hide_mesg', args.hide_mesg) def __from_file(self, path): if path is None: path = self._default_path # Resolve to absolute path by either expanding '~' or # resolving the relative path if path[0] == '~': path = os.path.expanduser(path) else: path = os.path.abspath(path) self._logger.info("Loading config from %s", path) # If theere is no config file at the location specified # create one with default values if not os.path.isfile(path): self.__copy_config(path) else: with open(path, 'r') as yaml_file: config = yaml.load(yaml_file, Loader=Loader) flat = self.__flatten_config(config) self.__insert_file(flat) def __insert_file(self, flat): for key, value in flat.items(): self.set(key, value) # Source code adapted from Imran on StackOverflow # https://stackoverflow.com/a/6027615 def __flatten_config(self, config, parent_key='', sep='.'): items = [] for key, value in config.items(): new_key = parent_key + sep + key if parent_key else key if isinstance(value, collections.MutableMapping): items.extend( self.__flatten_config(value, new_key, sep=sep).items()) else: items.append((new_key, value)) return dict(items) def __copy_config(self, path): try: self._logger.debug("Copying default config") source = pkg_resources.resource_filename( 'bitwarden_pyro.resources', 'config') dirname = os.path.dirname(path) if not os.path.isdir(dirname): os.makedirs(dirname) copy(source, path) except IOError: raise ConfigException("Failed to copy default config") def __create_config(self, path): """DEPRECATED! Use __copy_config instead""" self._logger.debug("Creating new config from defaults") dirname = os.path.dirname(path) if not os.path.isdir(dirname): os.makedirs(dirname) with open(path, 'w') as file: yaml.dump(self._config, file, Dumper=Dumper) def dump(self): """Flatten and convert to string all config data""" flat = self.__flatten_config(self._config) lines = [] for key, value in flat.items(): lines.append(f"{key}={value}") return "\n".join(lines) def get(self, key): """Retrieve value of a single config key""" path = key.split('.') option = self._config.get(path[0]) for idx, section in enumerate(path[1:]): if option is None: missing_path = ".".join(path[:idx]) raise ConfigException( f"Config key {missing_path} could not be found") option = option.get(section) return option def set(self, key, value): """Set the value of a single config key""" path = key.split('.') # Test to see if the key is valid current = self.get(key) if current is None: raise ConfigException(f"Config key could not be set '{key}'") option = self._config.get(path[0]) for section in path[1:-1]: option = option.get(section) if not isinstance(value, str): value = str(value) option[path[-1]] = value def add_converter(self, name, converter): """Dynamically generate a getter method using a custom converter""" def getter(self, key): raw = self.get(key) return converter(raw) getter.__name__ = f"get_{name}" setattr(self.__class__, getter.__name__, getter) @staticmethod def get_default(section, option): """Get the default value of a config key""" return ConfigLoader._default_values.get(section).get(option)
class Session: """Retrieve and store bitwarden session key using keyctl""" KEY_NAME = "bw_session" DEFAULT_TIMEOUT = 900 EXECUTABLE = 'keyctl' def __init__(self, auto_lock=None): # Interval in seconds for locking the vault self.auto_lock = int(auto_lock) \ if auto_lock is not None else self.DEFAULT_TIMEOUT self.key = None self._logger = ProjectLogger().get_logger() self.__has_executable() def __has_executable(self): """Check whether the 'keyctl' can be found on the system""" if which(self.EXECUTABLE) is None: raise SessionException( f"'{self.EXECUTABLE}' could not be found on the system'" ) def has_key(self): """Return true if the key can be retrieved from system The key can be retrieved if auto locking is not zero or keyctl has the session data registered """ return self.auto_lock != 0 and self.__get_keyid() is not None def __get_keyid(self): """Retrieves key id of session data from keyctl""" try: self._logger.debug("Requesting key id from keyctl") request_cmd = f"{self.EXECUTABLE} request user {self.KEY_NAME}" proc = sp.run(request_cmd.split(), check=True, capture_output=True) keyid = proc.stdout.decode("utf-8").strip() return keyid except CalledProcessError: return None def lock(self): """Delete session data from keyctl Raises: LockException: Raised when the spawned process returns errors """ try: self._logger.info("Deleting key from keyctl and locking bw") keyctl_cmd = f"{self.EXECUTABLE} purge user {self.KEY_NAME}" sp.run(keyctl_cmd.split(), check=True, capture_output=True) bw_cmd = "bw lock" sp.run(bw_cmd.split(), check=True, capture_output=True) except CalledProcessError: raise LockException("Failed to delete key from keyctl") def unlock(self, password): """Unlock bw and store session data in keyctl""" try: self._logger.info("Unlocking bw using password") # Unlock bw vault and retrieve session key unlock_cmd = f"bw unlock {password}" proc = sp.run(unlock_cmd.split(), check=True, capture_output=True) # Extract session key from the process output output = proc.stdout.decode("utf-8").split("\n")[3] regex = r"BW_SESSION=\"(.*==)\"" self.key = re.search(regex, output).group(1) # Save to keyctl if there is a non-zero lock timeout set if self.auto_lock != 0: self._logger.info("Saving key to keyctl") keyid = self.__get_keyid() if keyid is not None: self._logger.info("Overwriting old key") send_cmd = f"echo {self.key}" padd_cmd = f"{self.EXECUTABLE} padd user {self.KEY_NAME} @u" proc = sp.Popen(send_cmd.split(), stdout=sp.PIPE) sp.check_output(padd_cmd.split(), stdin=proc.stdout) except CalledProcessError: raise UnlockException("Failed to unlock bw") def get_key(self): """Return the session key from memory or from keyctl""" try: self._logger.info("Started key retrieval sequence") if self.auto_lock == 0: self._logger.debug("Force locking vault") self.lock() # A key is already in memory if self.key is not None: self._logger.debug("Returning key already in memory") return self.key # No key is in memory and storing the session key is allowed if self.auto_lock != 0: keyid = self.__get_keyid() if keyid is None: raise KeyReadException("Key was not found in keyctl") self._logger.debug("Retrieving key from keyctl") refresh_cmd = f"{self.EXECUTABLE} timeout {keyid} {self.auto_lock}" sp.run(refresh_cmd.split(), check=True) pipe_cmd = f"{self.EXECUTABLE} pipe {keyid}" proc = sp.run(pipe_cmd.split(), check=True, capture_output=True) self.key = proc.stdout.decode("utf-8").strip() return self.key raise KeyReadException( "Program is in an unknown state." ) except CalledProcessError: raise KeyReadException("Failed to retrieve session key")
class Clipboard: """Interface with the clipboard to get, set and clear""" _tools = { 'wayland': { 'wl-copy': { ClipboardEvents.GET: 'wl-paste', ClipboardEvents.SET: 'wl-copy', ClipboardEvents.CLEAR: 'wl-copy --clear' } }, 'x11': { 'xclip': { ClipboardEvents.GET: 'xclip -selection clipboard -o', ClipboardEvents.SET: 'xclip -selection clipboard -r', ClipboardEvents.CLEAR: 'echo lul | xclip -selection clipboard -r' }, 'xsel': { ClipboardEvents.GET: 'xsel --clipboard', ClipboardEvents.SET: 'xsel --clipboard --input', ClipboardEvents.CLEAR: 'xsel --clipboard --delete' }} } def __init__(self, clear): self.clear = clear self._exec = init_executable(self._tools) self._logger = ProjectLogger().get_logger() def get(self): """Get the contents of the clipboard""" return self.__emulate_clipboard(ClipboardEvents.GET) def set(self, value): """Set the contents of the clipboard""" self.__emulate_clipboard(ClipboardEvents.SET, value) if self.clear >= 0: sleep(self.clear) self._logger.info("Clearing clipboard") self.__clear() def __clear(self): self.__emulate_clipboard(ClipboardEvents.CLEAR) def __emulate_clipboard(self, action, value=None): """Interact with the clipboard""" try: self._logger.debug("Interacting with clipboard: %s", action) command = self._exec.get(action) if command is None: raise ClipboardException( f"Action '{action}' not supported by clipboard" ) self._logger.debug("Executing command %s", command) input_cmd = None if "|" in command: cmds = command.split("|") input_cmd = cmds[0] command = cmds[1] elif value is not None: input_cmd = f"echo {value}" if input_cmd is not None: input_cmd = input_cmd.split(" ", 2) echo_proc = sp.Popen(input_cmd, stdout=sp.PIPE) output = sp.Popen(command.split(), stdin=echo_proc.stdout) return None output = sp.check_output(command.split()) return output except CalledProcessError: raise ClipboardException("Failed to execute clipboard executable")
class Focus: """Select and focus specific windows""" def __init__(self, enabled, arguments): self._logger = ProjectLogger().get_logger() self._enabled = enabled self._arguments = arguments self.__check_execs() if self._enabled: self._logger.info("Focus has been enabled") def __check_execs(self): execs = ('slop', 'wmctrl') for exec_name in execs: if which(exec_name) is None: self._logger.warning("Disabling Focus, '%s' is not installed", exec_name) self.enabled = False def __select_window(self): self._logger.debug("Selecting window") cmd = "slop -f %i -t 999999" if self._arguments is not None: cmd += f" {self._arguments}" proc = sp.run(cmd.split(), check=False, capture_output=True) if proc.returncode != 0: return None return proc.stdout.decode("utf-8") def __focus_window(self, window_id): try: self._logger.debug("Focusing window: %s", window_id) cmd = f"wmctrl -i -a {window_id}" sp.run(cmd.split(), check=True, capture_output=True) except CalledProcessError: raise FocusException("Failed to focus window") def select_window(self): """Select and focus a window with slop and wmctrl""" if not self._enabled: self._logger.debug("Select window functionality is not enabled") return window_id = self.__select_window() if window_id is None: self._logger.info("Window selection has been aborted") return False self.__focus_window(window_id) return True def is_enabled(self): """Return True if feature is enabled""" return self._enabled
class Vault: """Load, get and filter items from bitwarden""" def __init__(self, expiry): self._items = None self._key = None self._filter = None self._cache = Cache(expiry) self._logger = ProjectLogger().get_logger() def has_cache(self): """Returns true if the cache has any available items""" return self._cache.has_items() def set_key(self, key): """Set the session key needed to access bw""" self._logger.debug("Vault key set") self._key = key def set_filter(self, folder_filter): """Set the folder filter used when getting items""" self._logger.debug("Vault folder filter set") self._filter = folder_filter def has_filter(self): """Returns true if the folder filter is set""" return self._filter is not None def get_filter(self): """Returns the folder filter, or None if not set""" return self._filter def sync(self): """Forces an items sync from bitwarden""" try: self._logger.info("Syncing items with bitwarden") sync_cmd = f"bw sync --session {self._key}" sp.run(sync_cmd.split(), capture_output=True, check=True) except CalledProcessError: raise SyncException("Failed to force a bitwarden sync") def __get_item_property(self, item, field): try: self._logger.info("Requesting %s from bitwarden", field) cmd = ['bw', '--session', self._key, 'get', field, item['id']] proc = sp.run(cmd, capture_output=True, check=True) output = proc.stdout.decode("utf-8") return output except CalledProcessError: raise LoadException(f"Failed to retrieve {field} from bw") def get_item_full(self, item): """Get a single item's full data directly from bw""" if self._cache.has_items(): return json.loads(self.__get_item_property(item, 'item')) return item def get_item_topt(self, item): """Get a single item's TOTP data from bitwarden""" return self.__get_item_property(item, 'totp') def load_items(self, use_cache=True): """Load item data from bitwarden or cache""" try: if use_cache and self.has_cache(): self._logger.info("Loading items from cache") self._items = self._cache.get() else: self._logger.info("Loading items from bw") load_cmd = f"bw list items --session {self._key}" proc = sp.run(load_cmd.split(), capture_output=True, check=True) items_json = proc.stdout.decode("utf-8") self._items = json.loads(items_json) if self._cache.should_cache(): self._cache.save(self._items) except CalledProcessError: raise LoadException("Failed to load vault items from bitwarden") def get_folders(self): """Get all available folders from bw""" try: self._logger.info("Getting folders from bw") cmd = f"bw list folders --session {self._key}" proc = sp.run(cmd.split(), capture_output=True, check=True) folders = proc.stdout.decode("utf-8") return json.loads(folders) except CalledProcessError: raise LoadException("Failed to load vault items from bitwarden") def get_items(self): """Get currently loaded items, after applying available filters""" if not self._filter: return self._items return [ i for i in self._items if i.get('folderId') is not None and i.get('folderId') == self._filter['id'] ] def get_by_name(self, name): """Get items filtered by name""" items = [i for i in self._items if i['name'] == name] if len(items) == 1: return items[0] return items