Fix (NVDA-critical): weak-password state must never pop a modal at startup

Ed launched 5.6 with a profile carrying the old weak "Games" password and hit a
show-stopper: a dialog he could neither read nor dismiss, and the window came up on
the connectivity screen instead of minimised. Cause: ExplainWeakPasswordIfNeeded fired
a modal TaskDialog automatically from RecomputeAudioCrypto on profile load / auto-
connect, via ForegroundDialog.Show — which also drags the window to the foreground
(hence the ignored "minimised"). A modal that steals focus mid-startup, before NVDA
can reach it, locks a screen-reader user out completely.

Fix: no automatic modal on the weak-password path. The state is surfaced NON-modally
and persistently in the status line (UpdateStatus, via the new pure WeakPasswordBlocks-
Audio) — NVDA reads it at the user's own pace, and it leads the line so it's spoken
first. The guided modal prompt is kept ONLY for the user-INITIATED streaming tick
(EnsureStreamingPassword), where the user just pressed a key so focus is clean and the
dialog is reachable; that path is already suppressed during startup profile-apply, so
there is now no startup modal at all. WeakPasswordBlocksAudio correctly stays false for
a strong password still deriving off-thread (key null, Critique null), so that transient
never shows the warning.

Test: WeakPasswordBlocksAudio pinned (weak+no-key true; empty/strong-deriving/has-key
false). Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-27 11:02:50 +01:00
co-authored by Claude Fable 5
parent 93bf1d86eb
commit 027ba905e7
2 changed files with 32 additions and 23 deletions
+22 -22
View File
@@ -7188,7 +7188,12 @@ public sealed partial class MainForm : Form
: "not receiving";
var peerCount = knownPeers.Count;
var hbSummary = heartbeatService?.GetHealthSummary() ?? "no peers";
statusLabel.Text = $"Connected for {since}. {peerCount} peer(s) known. {sendText}. {receiveText}. Heartbeat: {hbSummary}.";
// Weak-password block surfaced here, NON-modally, so NVDA reads it without a startup dialog
// trap (2026-07-27). Leads the status line so it's the first thing spoken.
var weakPrefix = WeakPasswordBlocksAudio(currentProfilePassword, currentAudioKey is not null)
? "No audio: this profile's password is too weak to protect it — change it in the File menu, “Change this profile's password”, on every machine you connect with. "
: "";
statusLabel.Text = $"{weakPrefix}Connected for {since}. {peerCount} peer(s) known. {sendText}. {receiveText}. Heartbeat: {hbSummary}.";
bool streaming = connected && (sender.IsRunning || receiver.IsRunning);
healthLabel.Text = connected
? streaming ? "Health: streaming" : "Health: idle"
@@ -8345,33 +8350,28 @@ public sealed partial class MainForm : Form
receiver.AudioFingerprint = currentAudioFingerprint;
}
/// <summary>Since 5.6 the derivation rule refuses a WEAK password (key comes back null), so a
/// profile that auto-connects at startup with an old guessable password must not just sit
/// silently dead — say why, once, and point at the fix. The interactive tick path has its own
/// guided flow (EnsureStreamingPassword); this catches every other route in.</summary>
/// <summary>Since 5.6 the derivation rule refuses a WEAK password (key comes back null). This runs
/// automatically on profile load / auto-connect, so it must NOT pop a modal: a startup dialog that
/// steals focus before NVDA can reach it — and drags the window out of the tray to show it — locked
/// a blind user out completely (Ed, 2026-07-27, "it will fuck over all nvda users"). The weak state
/// is surfaced NON-modally and persistently in the status line instead (see <see cref="UpdateStatus"/>
/// and <see cref="WeakPasswordBlocksAudio"/>), which NVDA reads at the user's own pace. The guided
/// modal prompt is reserved for the user-INITIATED streaming tick (<see cref="EnsureStreamingPassword"/>),
/// where the user just pressed a key so focus is clean and the dialog is reachable. Here: log once.</summary>
private void ExplainWeakPasswordIfNeeded(string? pw)
{
if (string.IsNullOrEmpty(pw) || currentAudioKey is not null || weakPasswordExplained) return;
weakPasswordExplained = true;
logFile.Event("audio crypto: profile password fails the 5.6 strength rule — no audio until it's changed");
var advice = PasswordStrength.Critique(pw) ?? "";
BeginInvoke(() =>
{
var page = new TaskDialogPage
{
Caption = "Password needs strengthening",
Heading = "No audio until this profile's password is stronger",
Text = "From this version, RemSound refuses to stream on a password that's easy to guess. "
+ advice + " Change it via the File menu, “Change this profile's password” — on every machine that uses it.",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page));
});
logFile.Event("audio crypto: profile password fails the 5.6 strength rule — no audio until it's changed (shown in the status line)");
}
/// <summary>Pure, testable: is audio blocked purely because the current profile password is too
/// weak (as opposed to no password, or a strong password still deriving off-thread)? Drives the
/// status-line warning. A strong password mid-derive has a null key too, but Critique returns null
/// for it, so this stays false there — it fires only for a genuinely guessable password.</summary>
internal static bool WeakPasswordBlocksAudio(string? password, bool haveKey) =>
!string.IsNullOrEmpty(password) && !haveKey && PasswordStrength.Critique(password) is not null;
// Bumped on every password change so a slow background derive that finishes AFTER a newer change
// knows to discard its now-stale result (see RecomputeAudioCrypto).
private int cryptoGeneration;
+10 -1
View File
@@ -2560,7 +2560,16 @@ internal static class SelfTest
Check(RemSoundCrypto.IsCached("") && RemSoundCrypto.IsCached("Games"),
"empty and weak passwords must count as 'no work' (they never trigger a blocking derive)");
return "weak+common refused with advice; derivation refuses weak everywhere; results cached (no repeat PBKDF2)";
// The weak-password state must surface NON-modally at startup (no focus-trapping dialog that
// NVDA can't reach — the 2026-07-27 launch bug). WeakPasswordBlocksAudio drives the status line:
// true only for a genuinely weak password with no key; NOT for empty, NOT for a strong password
// still deriving off-thread (key null but Critique null), NOT once the key is present.
Check(MainForm.WeakPasswordBlocksAudio("Games", haveKey: false), "a weak password with no key must flag the status warning");
Check(!MainForm.WeakPasswordBlocksAudio("", haveKey: false), "no password is not a 'weak password' block");
Check(!MainForm.WeakPasswordBlocksAudio("kettle9tiger42moon", haveKey: false), "a STRONG password still deriving (key null, Critique null) must NOT show the weak warning");
Check(!MainForm.WeakPasswordBlocksAudio("Games", haveKey: true), "once a key exists the warning clears");
return "weak+common refused with advice; derivation refuses weak everywhere; cached; weak-block surfaced non-modally";
}
/// <summary>The relay address-proof (2026-07-27): an AddrCheck cookie arriving at the receiver