示例#1
0
    def __init__(self, expiry):
        self._items = None
        self._key = None
        self._filter = None

        self._cache = Cache(expiry)
        self._logger = ProjectLogger().get_logger()
示例#2
0
class AutoType:
    """Emulate keyboard and automatically type strings and keys"""

    _tools = {'x11': ['xdotool'], 'wayland': ['sudo ydotool']}

    def __init__(self):
        self._exec = init_executable(self._tools)
        self._logger = ProjectLogger().get_logger()

    def string(self, string):
        """Type a string emulating a keyboard"""

        self.__emulate_keyboard('type', string)

    def key(self, key):
        """Type a single key emulating a keyboard"""

        self.__emulate_keyboard('key', key)

    def __emulate_keyboard(self, action, value):
        """Emulate keyboard input"""

        try:
            self._logger.debug("Emulating keyboard input for %s", action)
            type_cmd = f"{self._exec} {action} {value}"
            sp.run(type_cmd.split(), check=True, capture_output=True)
        except CalledProcessError:
            raise AutoTypeException(
                "Failed to run process emulating keyboard input")
示例#3
0
    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()
示例#4
0
    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")
示例#5
0
    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()
示例#6
0
 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()
示例#7
0
def init_executable(tools):
    """Find the most appropriate executables based on session type"""

    logger = ProjectLogger().get_logger()

    session_type = os.getenv('XDG_SESSION_TYPE')
    logger.debug("Initialising executable")

    # If session is a supported one
    if session_type is not None:
        logger.debug('Detected session type: %s', session_type)
        desktop_tools = tools.get(session_type)
        # If there are tools defined for the current tool_group
        if desktop_tools is None:
            raise UnsupportedDesktopException(
                f"Desktop session not supported: {session_type}")

        return __find_executable(desktop_tools)
    # If session is not supported, try and make the best
    # guess based on available executables

    logger.warning("Could not read desktop session type from environment, " +
                   "trying to guess based on detected executables")

    # List of available executables
    detected = []
    for desktop, items in tools.items():
        for item in items:
            if which(item) is not None:
                detected.append((desktop, item))

    if len(detected) == 0:
        logger.debug("No supported executables found")
        return None

    # List of unique desktop sessions for which executables have
    # been found
    detected_sessions = {d[0] for d in detected}  # set comprehension

    # Available executables are all for the same desktop session
    if len(detected_sessions) == 1:
        return __find_executable([d[1] for d in detected])

    # If executables are from multiple desktop sessions, the best one
    # can't be picked automatically, as we can't assume the currently
    # running desktop session
    # Equivalent to len(detected_sessions) < 0
    raise NotDecisiveException(
        "Found too many supported executable to be able to make " +
        f"a guess: {detected}")
示例#8
0
def __find_executable(tools):
    """Return a single executable installed on the system from the list"""
    logger = ProjectLogger().get_logger()

    for tool in tools:
        if which(tool) is not None:
            logger.debug("Found valid executable '%s'", tool)

            if isinstance(tools, dict):
                return tools.get(tool)

            return tool

    # If no valid executable has been found, raise an error
    raise NoExecutableException(f"Could not find executable: '{tools}'")
示例#9
0
    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)
示例#10
0
class Notify:
    """Send desktop notifications using notify-send"""
    def __init__(self, icons=None):
        self._logger = ProjectLogger().get_logger()
        self._icon = self.__find_icon(icons)

    def __find_icon(self, icons):
        if icons is not None:
            for icon in icons:
                if os.path.isfile(icon):
                    self._logger.debug("Found a valid icon: %s", icon)
                    return icon

        # Use internal fallback icon
        path = pkg_resources.resource_filename('bitwarden_pyro.resources',
                                               'icon.svg')
        self._logger.debug("Using fallback icon: %s", path)
        return path

    def send(self, message, title='Bitwarden Pyro', timeout=None):
        """Send a dekstop notification"""

        try:
            self._logger.debug("Sending desktop notification")
            cmd = ['notify-send', title, message]

            if timeout is not None:
                cmd.extend(['--expire-time', f"{timeout}"])

            if self._icon is not None:
                cmd.extend(['--icon', self._icon])

            sp.run(cmd, check=True, capture_output=True)
        except CalledProcessError:
            raise NotifyException("Failed to send notification message")
示例#11
0
    def __init__(self, args):
        self._logger = ProjectLogger().get_logger()
        self._config = None

        self.__init_config(args)
        self.__init_converters()
示例#12
0
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
示例#13
0
 def __init__(self, icons=None):
     self._logger = ProjectLogger().get_logger()
     self._icon = self.__find_icon(icons)
示例#14
0
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")
示例#15
0
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}")
示例#16
0
 def __init__(self, clear):
     self.clear = clear
     self._exec = init_executable(self._tools)
     self._logger = ProjectLogger().get_logger()
示例#17
0
 def __init__(self):
     self._exec = init_executable(self._tools)
     self._logger = ProjectLogger().get_logger()
示例#18
0
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
示例#19
0
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
示例#20
0
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")
示例#21
0
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
示例#22
0
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)