diff --git a/libinithooks/dialog_wrapper.py b/libinithooks/dialog_wrapper.py index 10e9cc4..8b159e3 100644 --- a/libinithooks/dialog_wrapper.py +++ b/libinithooks/dialog_wrapper.py @@ -1,9 +1,10 @@ # Copyright (c) 2010 Alon Swartz # Copyright (c) 2020-2025 TurnKey GNU/Linux - import re import sys import dialog +import secrets +import string import traceback from io import StringIO from os import environ @@ -26,15 +27,46 @@ class Error(Exception): def password_complexity(password: str) -> int: """return password complexity score from 0 (invalid) to 4 (strong)""" - lowercase = re.search("[a-z]", password) is not None uppercase = re.search("[A-Z]", password) is not None number = re.search(r"\d", password) is not None nonalpha = re.search(r"\W", password) is not None - return sum([lowercase, uppercase, number, nonalpha]) +def generate_password(length: int = 20) -> str: + """Generate a cryptographically secure random password. + + Uses the secrets module (CSPRNG). Guarantees at least one character + from each of the 4 complexity categories (uppercase, lowercase, + digit, symbol). Avoids shell-problematic characters. + """ + if length < 12: + length = 12 + + uppercase = string.ascii_uppercase + lowercase = string.ascii_lowercase + digits = string.digits + symbols = "!@#%^&*_+-=?" + + required = [ + secrets.choice(uppercase), + secrets.choice(lowercase), + secrets.choice(digits), + secrets.choice(symbols), + ] + + all_chars = uppercase + lowercase + digits + symbols + remaining = [secrets.choice(all_chars) for _ in range(length - len(required))] + + chars = required + remaining + for i in range(len(chars) - 1, 0, -1): + j = secrets.randbelow(i + 1) + chars[i], chars[j] = chars[j], chars[i] + + return "".join(chars) + + class Dialog: def __init__(self, title: str, width: int = 60, height: int = 20) -> None: self.width = width @@ -44,10 +76,11 @@ def __init__(self, title: str, width: int = 60, height: int = 20) -> None: self.console.add_persistent_args(["--no-collapse"]) self.console.add_persistent_args(["--backtitle", title]) self.console.add_persistent_args(["--no-mouse"]) + self.console.add_persistent_args(["--colors"]) def _handle_exitcode(self, retcode: int) -> bool: logging.debug(f"_handle_exitcode(retcode={retcode!r})") - if retcode == self.console.ESC: # ESC, ALT+? + if retcode == self.console.ESC: text = "Do you really want to quit?" if self.console.yesno(text) == self.console.OK: sys.exit(0) @@ -61,7 +94,6 @@ def _calc_height(self, text: str) -> int: height = 6 for line in text.splitlines(): height += (len(line) // self.width) + 1 - return height def wrapper( @@ -102,21 +134,15 @@ def wrapper( return retcode def error(self, text: str) -> tuple[int, str]: - """'Error' titled message with single 'ok' button - Returns 'Ok'""" height = self._calc_height(text) return self.wrapper("msgbox", text, height, self.width, title="Error") def msgbox(self, title: str, text: str) -> tuple[int, str]: - """Titled message with single 'ok' button - Returns 'Ok'""" height = self._calc_height(text) logging.debug(f"msgbox(title={title!r}, text=)") return self.wrapper("msgbox", text, height, self.width, title=title) def infobox(self, text: str) -> tuple[int, str]: - """Untitled message with single 'ok' button - Returns 'Ok'""" height = self._calc_height(text) logging.debug(f"infobox(text={text!r}") return self.wrapper("infobox", text, height, self.width) @@ -129,14 +155,11 @@ def inputbox( ok_label: str = "OK", cancel_label: str = "Cancel", ) -> tuple[int, str]: - """Titled message with text input and single choice of 2 buttons - Returns 'Ok' or "Cancel'""" logging.debug( f"inputbox(title={title!r}, text=," + f" init={init!r}, ok_label={ok_label!r}," + f" cancel_label={cancel_label!r})" ) - height = self._calc_height(text) + 3 no_cancel = True if cancel_label == "" else False logging.debug( @@ -162,8 +185,6 @@ def yesno( yes_label: str = "Yes", no_label: str = "No", ) -> bool: - """Titled message with single choice of 2 buttons - Returns True ('Yes" button) or False ('No' button)""" height = self._calc_height(text) retcode = self.wrapper( "yesno", @@ -185,12 +206,9 @@ def menu( self, title: str, text: str, - # [(opt1, opt1_info), (opt2, opt2_info)] choices: list[tuple[str, str]], ) -> str: - """Titled message with single choice of options & 'ok' button - Returns selected option - e.g. 'opt1'""" - _, choice = self.wrapper( # return_code, choice + _, choice = self.wrapper( "menu", text, self.height, @@ -209,10 +227,122 @@ def get_password( pass_req: int = 8, min_complexity: int = 3, blacklist: list[str] | None = None, + offer_generate: bool = True, + gen_length: int = 20, ) -> str | None: - """Validated titled message with password (redacted input) box & - 'ok' button - also accepts password limitations + """Validated password input with optional auto-generate. + + When offer_generate is True (default), presents a menu first: + - Generate: creates a strong random password, shows it to + the user, and asks for confirmation. + - Manual: traditional password input with complexity check. + + Fully backward compatible: existing calls without the new + parameters get the generate option automatically. Pass + offer_generate=False for the original behavior. + Returns password""" + + if offer_generate: + choice = self.menu( + title, + f"{text}\n\nChoose how to set this password:", + [ + ("Generate", "Strong random password (recommended)"), + ("Manual", "Type my own password"), + ], + ) + if choice == "Generate": + return self._generate_password_flow(title, gen_length) + + return self._manual_password_flow( + title, text, pass_req, min_complexity, blacklist + ) + + def _generate_password_flow( + self, title: str, length: int = 20 + ) -> str: + """Generate a strong password and show it to the user. + + Displays the password in a highlighted reverse-video box, + centered within the dialog, with a bold red warning.""" + while True: + password = generate_password(length) + + # Dialog content width is roughly self.width - 6 + content_width = self.width - 6 + + # Build reverse-video box + box_width = max(len(password) + 8, 36) + pw_pad_left = (box_width - len(password)) // 2 + pw_pad_right = box_width - len(password) - pw_pad_left + empty_line = " " * box_width + pw_line = " " * pw_pad_left + password + " " * pw_pad_right + + # Center the box within content area + box_margin = max((content_width - box_width - 4) // 2, 0) + margin = " " * box_margin + + # Center the title and warning + title_text = "Your generated password:" + title_pad = max((content_width - len(title_text)) // 2, 0) + + warning = ">>> SAVE THIS PASSWORD NOW <<<" + warn_pad = max((content_width - len(warning)) // 2, 0) + + note1 = "It will NOT be shown again." + note1_pad = max((content_width - len(note1) - 2) // 2, 0) + + note2 = "Store it in a password manager." + note2_pad = max((content_width - len(note2)) // 2, 0) + + text = ( + f"\n{' ' * title_pad}\ZbYour generated password:\Zn\n\n" + f"{margin}\Zb\Zr {empty_line} \Zn\n" + f"{margin}\Zb\Zr {pw_line} \Zn\n" + f"{margin}\Zb\Zr {empty_line} \Zn\n\n" + f"{' ' * warn_pad}\Zb\Z1{warning}\Zn\n\n" + f"{' ' * note1_pad}It will \ZbNOT\Zn be shown again.\n" + f"{' ' * note2_pad}Store it in a password manager." + ) + + height = 18 + width = max(self.width, box_width + 16) + self.wrapper("msgbox", text, height, width, title=title) + + # Confirmation dialog + q_text = "Did you save this password?" + q_pad = max((content_width - len(q_text)) // 2, 0) + + hint = "'Saved' = continue 'New' = generate another" + hint_pad = max((content_width - len(hint)) // 2, 0) + + confirm_text = ( + f"\n{' ' * q_pad}Did you save this password?\n\n" + f"{margin}\Zb\Zr {empty_line} \Zn\n" + f"{margin}\Zb\Zr {pw_line} \Zn\n" + f"{margin}\Zb\Zr {empty_line} \Zn\n\n" + f"{' ' * hint_pad}\Zb\Z2Saved\Zn = continue" + f" \Zb\Z1New\Zn = generate another" + ) + + confirmed = self.yesno( + "Confirm", confirm_text, + yes_label="Saved", no_label="New", + ) + + if confirmed: + return password + + def _manual_password_flow( + self, + title: str, + text: str, + pass_req: int = 8, + min_complexity: int = 3, + blacklist: list[str] | None = None, + ) -> str | None: + """Original manual password entry with validation.""" req_string = ( f"\n\nPassword Requirements\n - must be at least {pass_req}" " characters long\n - must contain characters from at" @@ -229,7 +359,6 @@ def get_password( height = self._calc_height(text + req_string) + 3 def ask(title: str, text: str) -> str: - """Titled input box (input redacted) & 'ok' button""" return self.wrapper( "passwordbox", text + req_string, @@ -254,7 +383,6 @@ def ask(title: str, text: str) -> str: ) continue elif not re.match(pass_req, password): - # TODO "Type analysis indicates code is unreachable"?! self.error("Password does not match complexity requirements.") continue @@ -279,6 +407,7 @@ def ask(title: str, text: str) -> str: for item in blacklist: if item in password: found_items.append(item) + if found_items: self.error( f"Password can NOT include these characters: {blacklist}." @@ -292,15 +421,13 @@ def ask(title: str, text: str) -> str: self.error("Password mismatch, please try again.") def get_email(self, title: str, text: str, init: str = "") -> str | None: - """Vaidated input box (email) with optional prefilled value and 'Ok' - button - Returns email""" logging.debug( f"get_email(title={title!r}, text=, init={init!r})" ) while 1: email = self.inputbox(title, text, init, "Apply", "")[1] logging.debug(f"get_email(...) email={email!r}") + if not email: self.error("Email is required.") continue @@ -312,8 +439,6 @@ def get_email(self, title: str, text: str, init: str = "") -> str | None: return email def get_input(self, title: str, text: str, init: str = "") -> str | None: - """Input box within optional prefilled value & 'Ok' button - Returns input""" while 1: s = self.inputbox(title, text, init, "Apply", "")[1] if not s: