feat: offer a generated password in the first boot dialogs #27

Merged
marcos-mendez merged 3 commits from feat/generated-password into master 2026-09-30 02:57:51 -03:00
marcos-mendez commented 2026-09-30 02:31:50 -03:00 (Migrated from github.com)

Fixes #23.

get_password() offers a generated password. This is turnkeylinux/inithooks#71 by Marcos Mendez, as revised in #74 (typing and docstrings), with the points from the #74 review applied.

Behaviour

  • A menu comes first: Generate (recommended) or Manual. Every existing caller gets it with no change: 30rootpass through setpass.py, 35mysqlpass through dbpass.py (keel-mariadb), and 40wordpress through wordpress.py (keel-wordpress). This holds on the boot run and under keel-init/turnkey-init alike, because both run the same hooks.
  • Generate draws 20 characters with secrets.choice (the CSPRNG). The alphabet has 59 characters, A-Z a-z 2-9 without 0 O o 1 l I, plus - . ~. That is about 117 bits.
    • The symbols are unreserved in RFC 3986. None of them needs escaping in a shell word, a URL, a single-quoted PHP string, a pymysql parameter or a dialog text.
    • = is excluded because the hooks read KEY=value with IFS='=' read, which drops a trailing =. I checked this.
    • _ is excluded because password_complexity() does not count it as a symbol.
    • The first and last characters are always a letter or a digit, so no leading - or ~.
    • A candidate must pass password_problem(), the same checks Manual applies (pass_req, min_complexity up to 4, blacklist). Its length is max(gen_length, pass_req), and single blacklisted characters are left out of the alphabet. If no candidate can pass (a pass_req regex), the operator is told so and gets Manual.
  • The password is shown in bold reverse video. It is returned only after the operator answers Saved. New discards it and shows another. offer_generate=False gives the old prompt alone.

From the #74 review:

  • A length under 12 raises ValueError; it is not bumped.
  • Blacklisted characters can be configured (the " case).
  • Only \Zb/\Zr attributes are used, per call, so there is no persistent --colors and nothing to detect.

Found on the real dialog

  • Collapsed band. dialog collapses a run of spaces after a \Z code, so #71's band showed as one reverse cell on each side. The band is now one space wider than the password.
  • Typed passwords in dialog.log. At DEBUG (DIALOG_DEBUG), wrapper() wrote every answer to /var/log/dialog.log, typed passwords included. A password box's value is now redacted.
  • 35mysqlpass hung. dialog draws its screen on its stdout. dbpass.py and wordpress.py have their stdout captured by the hook's < <(...), so on master the console showed nothing, dialog exited 3 and the wrapper looped on "Caught exception": the first boot hung in 35mysqlpass. That may be part of what #24 saw. While a widget runs, it now draws on the controlling terminal whenever stdout is not a tty. This also keeps a generated password out of the hook's pipe and out of its stderr, which is the journal.
  • ESC on menus and password boxes. ESC on a menu or a password box now asks whether to quit. Before, the (code, value) tuple never matched ESC.

#74 itself has two bugs that I did not bring in. wrapper() checks the stale retcode (0), so ESC is never handled. menu() returns return_tuple[0], which is the exit code, not the tag, so Generate could never be chosen.

Tests

tests/test_dialog_wrapper.py has 57 tests and runs through tests/fake_dialog.py, a scripted stand-in for pythondialog, so no terminal is needed. It covers:

  • the generate path
  • a refused confirmation
  • the manual path and its refusals
  • offer_generate=False
  • the alphabet and length rules
  • that neither a generated nor a typed password appears in a DEBUG file log, and that the inithooks log is not opened
  • that the screen reaches the terminal while a captured stdout keeps only KEY=value
  • ESC

dialog_wrapper.py leaves the coverage omit list at 98 percent. The two misses are the DIALOG_DEBUG line at import and an inherited, unreachable branch of get_domain. The Python total is 99 percent.

Real dialog on VM1 (TurnKey 19, dialog 1.3-20250116, pythondialog 3.5.3)

These runs used script(1), touched nothing outside /tmp/k23, and removed it afterwards.

  • Generate, New, Saved. Two different passwords were shown, and the one returned was the last one shown. dialog.log at DEBUG had no password. ps showed dialog --file /tmp/pythondialog.tmp* (mode 0600), so the password is not in argv.
  • 35mysqlpass's read loop around keel-mariadb's dbpass.py. Generate gave the hook DB_PASS of 20 characters, with an empty stderr. Manual gave Typed-Secret-9. The same loop against master's wrapper showed nothing and hung.

Coordination with #25

This PR does not touch run, keel-init or the lock. #25 merged while this was in progress, so master is merged into the branch (a merge commit, no force push). The three textual conflicts are resolved to keep both entries: debian/changelog has keel11 over keel10, COVERAGE.md has both sections, and pyproject.toml includes both dialog_wrapper.py and init_lock.py. With the merge, the Python suite is 359 passed and 3 skipped at 99 percent, and the bats suite passes.

Fixes #23. `get_password()` offers a generated password. This is turnkeylinux/inithooks#71 by Marcos Mendez, as revised in #74 (typing and docstrings), with the points from the #74 review applied. ## Behaviour - A menu comes first: **Generate (recommended)** or **Manual**. Every existing caller gets it with no change: 30rootpass through `setpass.py`, 35mysqlpass through `dbpass.py` (keel-mariadb), and 40wordpress through `wordpress.py` (keel-wordpress). This holds on the boot run and under keel-init/turnkey-init alike, because both run the same hooks. - **Generate** draws 20 characters with `secrets.choice` (the CSPRNG). The alphabet has 59 characters, `A-Z a-z 2-9` without `0 O o 1 l I`, plus `- . ~`. That is about 117 bits. - The symbols are unreserved in RFC 3986. None of them needs escaping in a shell word, a URL, a single-quoted PHP string, a pymysql parameter or a dialog text. - `=` is excluded because the hooks read `KEY=value` with `IFS='=' read`, which drops a trailing `=`. I checked this. - `_` is excluded because `password_complexity()` does not count it as a symbol. - The first and last characters are always a letter or a digit, so no leading `-` or `~`. - A candidate must pass `password_problem()`, the same checks Manual applies (pass_req, min_complexity up to 4, blacklist). Its length is `max(gen_length, pass_req)`, and single blacklisted characters are left out of the alphabet. If no candidate can pass (a `pass_req` regex), the operator is told so and gets Manual. - The password is shown in bold reverse video. It is returned only after the operator answers **Saved**. **New** discards it and shows another. `offer_generate=False` gives the old prompt alone. From the #74 review: - A length under 12 raises `ValueError`; it is not bumped. - Blacklisted characters can be configured (the `"` case). - Only `\Zb`/`\Zr` attributes are used, per call, so there is no persistent `--colors` and nothing to detect. ## Found on the real dialog - **Collapsed band.** dialog collapses a run of spaces after a `\Z` code, so #71's band showed as one reverse cell on each side. The band is now one space wider than the password. - **Typed passwords in dialog.log.** At DEBUG (`DIALOG_DEBUG`), `wrapper()` wrote every answer to `/var/log/dialog.log`, typed passwords included. A password box's value is now redacted. - **35mysqlpass hung.** dialog draws its screen on its stdout. `dbpass.py` and `wordpress.py` have their stdout captured by the hook's `< <(...)`, so on master the console showed nothing, dialog exited 3 and the wrapper looped on "Caught exception": the first boot hung in 35mysqlpass. That may be part of what #24 saw. While a widget runs, it now draws on the controlling terminal whenever stdout is not a tty. This also keeps a generated password out of the hook's pipe and out of its stderr, which is the journal. - **ESC on menus and password boxes.** ESC on a menu or a password box now asks whether to quit. Before, the `(code, value)` tuple never matched ESC. #74 itself has two bugs that I did not bring in. `wrapper()` checks the stale `retcode` (`0`), so ESC is never handled. `menu()` returns `return_tuple[0]`, which is the exit code, not the tag, so Generate could never be chosen. ## Tests `tests/test_dialog_wrapper.py` has 57 tests and runs through `tests/fake_dialog.py`, a scripted stand-in for pythondialog, so no terminal is needed. It covers: - the generate path - a refused confirmation - the manual path and its refusals - `offer_generate=False` - the alphabet and length rules - that neither a generated nor a typed password appears in a DEBUG file log, and that the inithooks log is not opened - that the screen reaches the terminal while a captured stdout keeps only `KEY=value` - ESC `dialog_wrapper.py` leaves the coverage omit list at 98 percent. The two misses are the `DIALOG_DEBUG` line at import and an inherited, unreachable branch of `get_domain`. The Python total is 99 percent. ## Real dialog on VM1 (TurnKey 19, dialog 1.3-20250116, pythondialog 3.5.3) These runs used `script(1)`, touched nothing outside `/tmp/k23`, and removed it afterwards. - **Generate, New, Saved.** Two different passwords were shown, and the one returned was the last one shown. `dialog.log` at DEBUG had no password. `ps` showed `dialog --file /tmp/pythondialog.tmp*` (mode 0600), so the password is not in argv. - **35mysqlpass's read loop around keel-mariadb's `dbpass.py`.** Generate gave the hook `DB_PASS` of 20 characters, with an empty stderr. Manual gave `Typed-Secret-9`. The same loop against master's wrapper showed nothing and hung. ## Coordination with #25 This PR does not touch `run`, `keel-init` or the lock. #25 merged while this was in progress, so master is merged into the branch (a merge commit, no force push). The three textual conflicts are resolved to keep both entries: `debian/changelog` has keel11 over keel10, `COVERAGE.md` has both sections, and `pyproject.toml` includes both `dialog_wrapper.py` and `init_lock.py`. With the merge, the Python suite is 359 passed and 3 skipped at 99 percent, and the bats suite passes.
Sign in to join this conversation.
No description provided.