-
Notifications
You must be signed in to change notification settings - Fork 16
Marcos' auto-password generation + bits... #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| # Copyright (c) 2010 Alon Swartz <alon@turnkeylinux.org> | ||
| # Copyright (c) 2020-2025 TurnKey GNU/Linux <admin@turnkeylinux.org> | ||
|
|
||
| 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"]) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Color support should be detected automatically, if we force it it'll make non-color supporting terminals break output inconsistent output. |
||
|
|
||
| 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,12 +94,11 @@ def _calc_height(self, text: str) -> int: | |
| height = 6 | ||
| for line in text.splitlines(): | ||
| height += (len(line) // self.width) + 1 | ||
|
|
||
| return height | ||
|
|
||
| def wrapper( | ||
| self, dialog_name: str, text: str, *args, **kws | ||
| ) -> tuple[int, str]: | ||
| ) -> str | tuple[str, str]: | ||
| retcode = 0 | ||
| logging.debug( | ||
| f"wrapper(dialog_name={dialog_name!r}, text=<redacted>," | ||
|
|
@@ -83,7 +115,7 @@ def wrapper( | |
|
|
||
| while 1: | ||
| try: | ||
| retcode = method("\n" + text, *args, **kws) | ||
| return_value = method("\n" + text, *args, **kws) | ||
| logging.debug( | ||
| f"wrapper(dialog_name={dialog_name!r}, ...) -> {retcode!r}" | ||
| ) | ||
|
|
@@ -99,27 +131,33 @@ def wrapper( | |
| ) | ||
| self.msgbox("Caught exception", sio.getvalue()) | ||
|
|
||
| return retcode | ||
| return return_value | ||
|
|
||
| def error(self, text: str) -> tuple[int, str]: | ||
| def error(self, text: str) -> 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") | ||
| return str( | ||
| self.wrapper("msgbox", text, height, self.width, title="Error"), | ||
| ) | ||
|
|
||
| def msgbox(self, title: str, text: str) -> tuple[int, str]: | ||
| def msgbox(self, title: str, text: str) -> str: | ||
| """Titled message with single 'ok' button | ||
| Returns 'Ok'""" | ||
| height = self._calc_height(text) | ||
| logging.debug(f"msgbox(title={title!r}, text=<redacted>)") | ||
| return self.wrapper("msgbox", text, height, self.width, title=title) | ||
| return str( | ||
| self.wrapper("msgbox", text, height, self.width, title=title), | ||
| ) | ||
|
|
||
| def infobox(self, text: str) -> tuple[int, str]: | ||
| def infobox(self, text: str) -> 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) | ||
| return str( | ||
| self.wrapper("infobox", text, height, self.width), | ||
| ) | ||
|
|
||
| def inputbox( | ||
| self, | ||
|
|
@@ -128,22 +166,21 @@ def inputbox( | |
| init: str = "", | ||
| ok_label: str = "OK", | ||
| cancel_label: str = "Cancel", | ||
| ) -> tuple[int, str]: | ||
| ) -> tuple[str, str]: | ||
| """Titled message with text input and single choice of 2 buttons | ||
| Returns 'Ok' or "Cancel'""" | ||
| Returns tuple of 'ok'/'cancel' & the input string""" | ||
| logging.debug( | ||
| f"inputbox(title={title!r}, text=<redacted>," | ||
| + 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( | ||
| f"inputbox(...) [calculated height={height}," | ||
| f" no_cancel={no_cancel}]" | ||
| ) | ||
| return self.wrapper( | ||
| return_tuple = self.wrapper( | ||
| "inputbox", | ||
| text, | ||
| height, | ||
|
|
@@ -154,6 +191,8 @@ def inputbox( | |
| cancel_label=cancel_label, | ||
| no_cancel=no_cancel, | ||
| ) | ||
| assert isinstance(return_tuple, tuple) | ||
| return return_tuple | ||
|
|
||
| def yesno( | ||
| self, | ||
|
|
@@ -185,12 +224,13 @@ 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 | ||
| """Titled message with single choice of options & 'ok' button. | ||
| choices is a list of options, each option is a tuple of option tag and | ||
| option (short) description | ||
| Returns selected option tag""" | ||
| return_tuple = self.wrapper( | ||
| "menu", | ||
| text, | ||
| self.height, | ||
|
|
@@ -200,7 +240,8 @@ def menu( | |
| choices=choices, | ||
| no_cancel=True, | ||
| ) | ||
| return choice | ||
| assert isinstance(return_tuple, tuple) | ||
| return return_tuple[0] | ||
|
Comment on lines
+243
to
+244
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent handling of type assertions, earlier we wrapped a value in a |
||
|
|
||
| def get_password( | ||
| self, | ||
|
|
@@ -209,10 +250,129 @@ 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. | ||
|
|
||
| Returns password. | ||
| """ | ||
| 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. | ||
|
|
||
| Titled message with password (redacted input) box & 'ok' button. | ||
| Password is validated against defined rules (pass_req, min_complexity & | ||
| blacklist). Method will loop until deemed valid. | ||
| """ | ||
| req_string = ( | ||
| f"\n\nPassword Requirements\n - must be at least {pass_req}" | ||
| " characters long\n - must contain characters from at" | ||
|
|
@@ -254,7 +414,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 +438,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,7 +452,7 @@ 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' | ||
| """Validated input box (email) with optional prefilled value and 'Ok' | ||
| button | ||
| Returns email""" | ||
| logging.debug( | ||
|
|
@@ -301,6 +461,7 @@ def get_email(self, title: str, text: str, init: str = "") -> str | None: | |
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is actually quite nice. I will note that as bad as it is, we will need to remove the length < 12 check, or we should
assert(length >= 12)because if an appliance actually requires a password to be smaller than that, this will silently cause a broken password.