From 027ba905e7af5ba191709666a1b5c5d320fddb30 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:02:50 +0100 Subject: [PATCH] Fix (NVDA-critical): weak-password state must never pop a modal at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RemSound.App/MainForm.cs | 44 ++++++++++++++++++------------------ src/RemSound.App/SelfTest.cs | 11 ++++++++- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index ac0f4b3..4716492 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -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; } - /// 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. + /// 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 + /// and ), which NVDA reads at the user's own pace. The guided + /// modal prompt is reserved for the user-INITIATED streaming tick (), + /// where the user just pressed a key so focus is clean and the dialog is reachable. Here: log once. 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)"); } + /// 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. + 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; diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index dfd55ff..e984055 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -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"; } /// The relay address-proof (2026-07-27): an AddrCheck cookie arriving at the receiver