Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 188 additions & 27 deletions libinithooks/dialog_wrapper.py
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
Expand All @@ -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)
Comment on lines +37 to +67
Copy link
Copy Markdown
Member

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.



class Dialog:
def __init__(self, title: str, width: int = 60, height: int = 20) -> None:
self.width = width
Expand All @@ -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"])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)
Expand All @@ -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>,"
Expand All @@ -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}"
)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent handling of type assertions, earlier we wrapped a value in a str(...) call, here we assert it's type. We should do this consistently unless we have a good reason not too.


def get_password(
self,
Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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}."
Expand All @@ -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(
Expand All @@ -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
Expand Down