diff --git a/README.md b/README.md index 029e55b..75e7fc0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Based on the alternative [Bitwarden](https://bitwarden.com/) CLI [rbw](https://g - Autotype username and password (with a `tab` character in between) with `Alt+1` (and copy TOTP to clipboard) - Configure autotyping either as a keybinding or by having a `_autotype` field in your credential - Copy username, password or TOTP to the clipboard (`Alt+u`, `Alt+p` and `Alt+t`, respectively) +- Add new entries with `Alt+n` (automatically detects URLs from clipboard and generates secure passwords) - Show an autotype menu with all fields ## Usage diff --git a/docs/rofi-rbw.1.md b/docs/rofi-rbw.1.md index ab8acf1..1ff5a2b 100644 --- a/docs/rofi-rbw.1.md +++ b/docs/rofi-rbw.1.md @@ -9,7 +9,7 @@ # SYNOPSIS -| **rofi-rbw** \[**-h**] \[**\--version**] \[**\--action** {*type*,*copy*,*print*}] +| **rofi-rbw** \[**-h**] \[**\--version**] \[**\--action** {*type*,*copy*,*print*,*add*}] \[**\--target** {*username*,*password*,*totp*,*OTHER*}] \[**\--prompt** *PROMPT*] \[**\--selector-args** *SELECTOR_ARGS*] \[**\--clipboarder** *CLIPBOARDER*] \[**\--typer** *TYPER*] \[**\--selector** *SELECTOR*] @@ -34,9 +34,9 @@ Type, copy or print your credentials from Bitwarden using rofi. \--action, -a -: Possible values: type, copy, print +: Possible values: type, copy, print, add - Choose what to do with the selected characters: Directly type them with the "Typer", copy them to the clipboard using the "Clipboarder", or "print" them on stdout + Choose what to do with the selected characters: Directly type them with the "Typer", copy them to the clipboard using the "Clipboarder", "print" them on stdout, or "add" a new entry to the database \--target, -t @@ -122,6 +122,8 @@ Type, copy or print your credentials from Bitwarden using rofi. *alt+s* to sync the contents of the vault +*alt+n* to add a new entry (detects URLs from clipboard and generates secure passwords) + *alt+1* to autotype username and password, separated with a `tab` character *alt+2* to type the username diff --git a/src/rofi_rbw/abstractionhelper.py b/src/rofi_rbw/abstractionhelper.py index bead570..3ed47b1 100644 --- a/src/rofi_rbw/abstractionhelper.py +++ b/src/rofi_rbw/abstractionhelper.py @@ -8,3 +8,18 @@ def is_installed(executable: str) -> bool: def is_wayland() -> bool: return os.environ.get("WAYLAND_DISPLAY", False) is not None + +def extract_domain_from_url(url: str) -> str: + """Extract domain from URL, removing protocol, www, and path/query parameters.""" + import re + + # Remove protocol + url = re.sub(r'^https?://', '', url) + + # Remove www prefix + url = re.sub(r'^www\.', '', url) + + # Extract domain (everything before first slash or query parameter) + domain = url.split('/')[0].split('?')[0].split('#')[0] + + return domain diff --git a/src/rofi_rbw/argument_parsing.py b/src/rofi_rbw/argument_parsing.py index 0065193..1ae9521 100644 --- a/src/rofi_rbw/argument_parsing.py +++ b/src/rofi_rbw/argument_parsing.py @@ -101,6 +101,7 @@ def parse_arguments() -> argparse.Namespace: "Alt+t:copy:totp", "Alt+m::menu", "Alt+s:sync", + "Alt+n:add", ] ), help="Define keyboard shortcuts in the format ::, separated with a comma", diff --git a/src/rofi_rbw/clipboarder/clipboarder.py b/src/rofi_rbw/clipboarder/clipboarder.py index 312365f..933d7c7 100644 --- a/src/rofi_rbw/clipboarder/clipboarder.py +++ b/src/rofi_rbw/clipboarder/clipboarder.py @@ -34,6 +34,10 @@ def copy_to_clipboard(self, characters: str) -> None: def clear_clipboard_after(self, clear: int) -> None: pass + @abstractmethod + def read_from_clipboard(self) -> str: + pass + class NoClipboarderFoundException(Exception): def __str__(self) -> str: diff --git a/src/rofi_rbw/clipboarder/noop.py b/src/rofi_rbw/clipboarder/noop.py index 6c6bfd0..2b9b1d5 100644 --- a/src/rofi_rbw/clipboarder/noop.py +++ b/src/rofi_rbw/clipboarder/noop.py @@ -15,3 +15,6 @@ def copy_to_clipboard(self, characters: str) -> None: def clear_clipboard_after(self, clear: int) -> None: raise NoClipboarderFoundException() + + def read_from_clipboard(self) -> str: + raise NoClipboarderFoundException() diff --git a/src/rofi_rbw/clipboarder/wlclip.py b/src/rofi_rbw/clipboarder/wlclip.py index 96a0930..9f87a06 100644 --- a/src/rofi_rbw/clipboarder/wlclip.py +++ b/src/rofi_rbw/clipboarder/wlclip.py @@ -29,5 +29,8 @@ def clear_clipboard_after(self, clear: int) -> None: run(["wl-copy", "--clear"]) self.__last_copied_characters = None + def read_from_clipboard(self) -> str: + return self.__fetch_clipboard_content() + def __fetch_clipboard_content(self) -> str: return run(["wl-paste"], capture_output=True, encoding="utf-8").stdout diff --git a/src/rofi_rbw/clipboarder/xclip.py b/src/rofi_rbw/clipboarder/xclip.py index e45a1f7..7c7497d 100644 --- a/src/rofi_rbw/clipboarder/xclip.py +++ b/src/rofi_rbw/clipboarder/xclip.py @@ -30,5 +30,8 @@ def clear_clipboard_after(self, clear: int) -> None: self.copy_to_clipboard("") self.__last_copied_characters = None + def read_from_clipboard(self) -> str: + return self.__fetch_clipboard_content() + def __fetch_clipboard_content(self) -> str: return run(["xclip", "-o", "-selection", "clipboard"], capture_output=True, encoding="utf-8").stdout diff --git a/src/rofi_rbw/clipboarder/xsel.py b/src/rofi_rbw/clipboarder/xsel.py index 0acc010..684ae1c 100644 --- a/src/rofi_rbw/clipboarder/xsel.py +++ b/src/rofi_rbw/clipboarder/xsel.py @@ -30,6 +30,9 @@ def clear_clipboard_after(self, clear: int) -> None: run(["xsel", "--clear", "--clipboard"]) self.__last_copied_characters = None + def read_from_clipboard(self) -> str: + return self.__fetch_clipboard_content() + def __fetch_clipboard_content(self) -> str: return run( [ diff --git a/src/rofi_rbw/models/action.py b/src/rofi_rbw/models/action.py index ce2050b..b2a71c9 100644 --- a/src/rofi_rbw/models/action.py +++ b/src/rofi_rbw/models/action.py @@ -7,3 +7,4 @@ class Action(Enum): PRINT = "print" SYNC = "sync" CANCEL = "cancel" + ADD = "add" diff --git a/src/rofi_rbw/rbw.py b/src/rofi_rbw/rbw.py index 1217872..e3e1cf3 100644 --- a/src/rofi_rbw/rbw.py +++ b/src/rofi_rbw/rbw.py @@ -90,3 +90,35 @@ def __load_from_rbw(self, name: str, username: str, folder: Optional[str]) -> st def sync(self): run(["rbw", "sync"]) + + def generate_password(self, length: int = 16) -> str: + """Generate a new password using rbw generate command.""" + command = ["rbw", "generate", str(length)] + result = run(command, capture_output=True, encoding="utf-8") + + if result.returncode != 0: + raise Exception(f"Failed to generate password: {result.stderr}") + + return result.stdout.strip() + + def add_entry(self, name: str, username: str, uri: Optional[str] = None, folder: Optional[str] = None, password_length: int = 16) -> str: + """Add a new entry to the password database and return the generated password.""" + command = ["rbw", "generate", str(password_length), name] + + if username: + command.append(username) + + if uri: + command.extend(["--uri", uri]) + + if folder: + command.extend(["--folder", folder]) + + # Run the command to create entry with generated password + result = run(command, capture_output=True, encoding="utf-8") + + if result.returncode != 0: + raise Exception(f"Failed to add entry: {result.stderr}") + + # The generated password is returned in stdout + return result.stdout.strip() diff --git a/src/rofi_rbw/rofi_rbw.py b/src/rofi_rbw/rofi_rbw.py index ab0add5..831ecf5 100755 --- a/src/rofi_rbw/rofi_rbw.py +++ b/src/rofi_rbw/rofi_rbw.py @@ -53,6 +53,10 @@ def main(self) -> None: if selected_action == Action.CANCEL: return + if selected_action == Action.ADD: + self.__handle_add_entry() + return + entry = self.rbw.fetch_credentials(selected_entry) if self.args.use_cache: @@ -126,3 +130,91 @@ def __type_targets(self, detailed_entry: DetailedEntry, targets: List[Target]): self.clipboarder.copy_to_clipboard(detailed_entry.totp) if self.args.use_notify_send: run(["notify-send", "-u", "normal", "-t", "3000", "rofi-rbw", "totp copied to clipboard"], check=True) + + def __handle_add_entry(self) -> None: + """Handle adding a new entry to the password database.""" + from .abstractionhelper import extract_domain_from_url + + # Try to get URL from clipboard as a default + clipboard_url = "" + try: + clipboard_content = self.clipboarder.read_from_clipboard().strip() + + # Check if clipboard contains a URL + if clipboard_content.startswith(('http://', 'https://')) or ('.' in clipboard_content and not clipboard_content.isspace()): + clipboard_url = clipboard_content if clipboard_content.startswith(('http://', 'https://')) else f"https://{clipboard_content}" + except Exception: + # If clipboard reading fails, start with empty values + clipboard_url = "" + + # Prompt for URL (pre-filled with clipboard content if available) + url_prompt = f"URL{f' [{clipboard_url}]' if clipboard_url else ''}" + try: + result = self.selector.show_input_dialog(url_prompt, clipboard_url if clipboard_url else "") + if result is None: + return # User cancelled + + entered_url = result.strip() + # If user entered something, use that; if empty and we had clipboard content, use clipboard; otherwise no URL + if entered_url: + uri = entered_url if entered_url.startswith(('http://', 'https://')) else f"https://{entered_url}" + elif clipboard_url: + uri = clipboard_url + else: + uri = None + except Exception: + print("Failed to prompt for URL") + return + + # Extract domain for default name if we have a URI + if uri: + try: + domain = extract_domain_from_url(uri) + default_name = domain + except Exception: + default_name = "" + else: + default_name = "" + + # Prompt for entry name (pre-filled with domain if available) + name_prompt = f"Entry name{f' [{default_name}]' if default_name else ''}" + try: + result = self.selector.show_input_dialog(name_prompt, default_name if default_name else "") + if result is None: + return # User cancelled + + entered_name = result.strip() + if entered_name: + name = entered_name + elif default_name: + name = default_name + else: + print("Entry name is required") + return + except Exception: + print("Failed to prompt for entry name") + return + + # Prompt for username + try: + username = self.selector.show_input_dialog("Username", "") + if username is None: + return # User cancelled + + username = username.strip() + except Exception: + print("Failed to prompt for username") + return + + # Add entry to database (this will generate the password) + try: + password = self.rbw.add_entry(name, username, uri) + + # Copy password to clipboard + self.clipboarder.copy_to_clipboard(password) + print(f"Entry '{name}' added successfully. Password copied to clipboard.") + + # Sync to update the database + self.rbw.sync() + except Exception as e: + print(f"Error adding entry: {e}") diff --git a/src/rofi_rbw/selector/bemenu.py b/src/rofi_rbw/selector/bemenu.py index c8ef5ff..4d80dc4 100644 --- a/src/rofi_rbw/selector/bemenu.py +++ b/src/rofi_rbw/selector/bemenu.py @@ -75,3 +75,19 @@ def select_target( return None, Action.CANCEL return self._extract_targets(bemenu.stdout), None + + def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None: + """Show an input dialog using bemenu.""" + from subprocess import run + + bemenu = run( + ["bemenu", "-p", prompt], + input=default_value, + capture_output=True, + encoding="utf-8" + ) + + if bemenu.returncode != 0: + return None # User cancelled + + return bemenu.stdout.strip() diff --git a/src/rofi_rbw/selector/fuzzel.py b/src/rofi_rbw/selector/fuzzel.py index 017399f..d17b5ad 100644 --- a/src/rofi_rbw/selector/fuzzel.py +++ b/src/rofi_rbw/selector/fuzzel.py @@ -74,3 +74,21 @@ def select_target( return None, Action.CANCEL return self._extract_targets(fuzzel.stdout), None + + def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None: + """Show an input dialog using fuzzel.""" + from subprocess import run + + # Fuzzel doesn't have a direct dmenu mode, so we'll use a simple approach + # by creating a temporary input with the default value + fuzzel = run( + ["fuzzel", "--dmenu", "--prompt", prompt], + input=default_value, + capture_output=True, + encoding="utf-8" + ) + + if fuzzel.returncode != 0: + return None # User cancelled + + return fuzzel.stdout.strip() diff --git a/src/rofi_rbw/selector/rofi.py b/src/rofi_rbw/selector/rofi.py index 477093f..5270e80 100644 --- a/src/rofi_rbw/selector/rofi.py +++ b/src/rofi_rbw/selector/rofi.py @@ -139,3 +139,19 @@ def __format_action_and_targets(self, keybinding: Keybinding) -> str: return f"{keybinding.action.value.title()} {', '.join([target.raw for target in keybinding.targets])}" else: return keybinding.action.value.title() + + def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None: + """Show an input dialog using rofi.""" + from subprocess import run + + rofi = run( + ["rofi", "-dmenu", "-p", prompt], + input=default_value, + capture_output=True, + encoding="utf-8" + ) + + if rofi.returncode != 0: + return None # User cancelled + + return rofi.stdout.strip() diff --git a/src/rofi_rbw/selector/selector.py b/src/rofi_rbw/selector/selector.py index f900954..7e73c4b 100644 --- a/src/rofi_rbw/selector/selector.py +++ b/src/rofi_rbw/selector/selector.py @@ -1,4 +1,3 @@ -import re from abc import ABC, abstractmethod from typing import Dict, List, Tuple, Union @@ -65,6 +64,10 @@ def select_target( ) -> Tuple[Union[List[Target], None], Union[Action, None]]: pass + @abstractmethod + def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None: + pass + def _format_targets_from_entry(self, entry: DetailedEntry) -> List[str]: if isinstance(entry, Credentials): return self._format_targets_from_credential(entry) @@ -116,43 +119,77 @@ def _format_targets_from_card(self, card: Card) -> List[str]: def _format_targets_from_note(self, note: Note) -> List[str]: targets = [] if note.notes: - targets.append(f"Notes: {note.notes.replace('\n', '|')}") - for field in note.fields: - targets.append(f"{self._format_further_item_name(field.key)}: {field.masked_string()}") - + notes_text = note.notes.replace('\n', '|') + targets.append(f"Notes: {notes_text}") return targets def _format_further_item_name(self, key: str) -> str: - if key.lower() in ["username", "password", "totp"] or re.match(r"^URI \d+$", key): - return f"{key} (field)" - return key - - @staticmethod - def _extract_targets(output: str) -> List[Target]: - return [Target(line.split(":")[0]) for line in output.strip().split("\n")] - - @staticmethod - def _calculate_max_width(entries: List[Entry], show_folders: bool) -> int: - if show_folders: - return max(len(it.name) + len(it.folder) + 1 for it in entries) + """Format field names for display.""" + return key.replace('_', ' ').title() + + def _format_folder(self, entry: Entry, show_folders: bool) -> str: + """Format folder display for an entry.""" + if show_folders and entry.folder: + return f"{entry.folder}/" + return "" + + def _calculate_max_width(self, entries: List[Entry], show_folders: bool) -> int: + """Calculate the maximum width needed for entry names.""" + if not entries: + return 0 + + max_width = 0 + for entry in entries: + folder_width = len(entry.folder) + 1 if show_folders and entry.folder else 0 + name_width = len(entry.name) + total_width = folder_width + name_width + max_width = max(max_width, total_width) + + return max_width + + def justify(self, entry: Entry, max_width: int, show_folders: bool) -> str: + """Add spacing to justify entry display.""" + folder_width = len(entry.folder) + 1 if show_folders and entry.folder else 0 + name_width = len(entry.name) + current_width = folder_width + name_width + spaces_needed = max_width - current_width + return " " * max(0, spaces_needed) + + def _extract_targets(self, selected_text: str) -> List[Target]: + """Extract targets from selected text.""" + if not selected_text.strip(): + return [] + + # Parse the selected text to extract the target type + selected_text = selected_text.strip() + + if selected_text.startswith("Username:"): + return [Target("username")] + elif selected_text.startswith("Password:"): + return [Target("password")] + elif selected_text.startswith("TOTP:"): + return [Target("totp")] + elif selected_text.startswith("Notes:"): + return [Target("notes")] + elif selected_text.startswith("URI"): + return [Target("uri")] + elif selected_text.startswith("Number:"): + return [Target("number")] + elif selected_text.startswith("Cardholder:"): + return [Target("cardholder")] + elif selected_text.startswith("Brand:"): + return [Target("brand")] + elif selected_text.startswith("Expiry:"): + return [Target("expiry")] + elif selected_text.startswith("Code:"): + return [Target("code")] else: - return max(len(it.name) for it in entries) - - @staticmethod - def _format_folder(entry: Entry, show_folders: bool) -> str: - if not show_folders or not entry.folder: - return "" - return f"{entry.folder}/" - - @staticmethod - def justify(entry: Entry, max_width: int, show_folders: bool) -> str: - whitespace_length = max_width - len(entry.name) - if show_folders: - if entry.folder: - whitespace_length -= len(entry.folder) + 1 - return " " * whitespace_length - + # For custom fields, extract the field name + if ":" in selected_text: + field_name = selected_text.split(":")[0].strip() + return [Target(field_name)] + return [] class NoSelectorFoundException(Exception): def __str__(self) -> str: - return "Could not find a valid way to show the selection. Please check the required dependencies." + return "Could not find a valid selector. Please check the required dependencies." diff --git a/src/rofi_rbw/selector/wofi.py b/src/rofi_rbw/selector/wofi.py index fb61e67..e4771c1 100644 --- a/src/rofi_rbw/selector/wofi.py +++ b/src/rofi_rbw/selector/wofi.py @@ -75,3 +75,19 @@ def select_target( return None, Action.CANCEL return self._extract_targets(wofi.stdout), None + + def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None: + """Show an input dialog using wofi.""" + from subprocess import run + + wofi = run( + ["wofi", "--dmenu", "--prompt", prompt], + input=default_value, + capture_output=True, + encoding="utf-8" + ) + + if wofi.returncode != 0: + return None # User cancelled + + return wofi.stdout.strip()