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
183 changes: 154 additions & 29 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)


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"])

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,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(
Expand Down Expand Up @@ -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=<redacted>)")
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)
Expand All @@ -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=<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(
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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"
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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}."
Expand All @@ -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=<redacted>, 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
Expand All @@ -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:
Expand Down