v5.7: back out the enforced stronger passwords (restore cross-port compatibility)

The v5.6 password change went down badly and broke the other ports: raising PBKDF2 to
600k changed the key recipe, so the SAME password produced a DIFFERENT key on 5.6 vs
every other build (the iOS/TestFlight app, older desktops) — same password, no audio.
Ed: back it out so the other ports can use it again; a password is still required, just
suggest a strong one.

Reverted:
- PBKDF2 100k again (RemSoundCrypto) — the key recipe now matches the other ports, so
  the same password derives the same key and audio flows again. Pinned by a gate check
  so an accidental change can't silently re-break cross-port compat.
- ForPlainPassword no longer refuses a weak password: ANY non-empty password derives a
  key (encryption stays mandatory — empty still means no audio). Strength is not enforced.
- Removed every enforcement/nag: the streaming-tick force-strengthen, the startup
  weak-password dialog, the status-line weak warning, the app's weak-SERVICE-password
  launch + live nags, and the service-profile save-time block. The password dialogs just
  suggest a strong password in their hint now; PasswordStrength.cs deleted (now unused).

Someone who set a strong password because of 5.6 feels no difference on update — their
password still loads and works, no prompt (Ed's requirement).

Kept (separate from "stronger passwords", not backed out): signed updates, password-
sealed remote volume, service startup volume, the update time-window, the nonce widening,
and the relay address-proof. NOTE: sealed remote-volume still needs both ends on 5.6+ —
ordinary audio does not. Flagging in case full remote-volume interop with old ports is
wanted too.

Docs: About + readme + release notes rewritten for v5.7 (plain English, positive framing,
no "must update"). MANUAL.md regenerated. Version 5.7. Gate 70/70 (dropped the now-moot
streaming-strengthening step) + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-27 14:01:12 +01:00
co-authored by Claude Fable 5
parent cce3db8120
commit 2cf4948bfb
13 changed files with 103 additions and 443 deletions
+2 -2
View File
@@ -702,7 +702,7 @@ Where| What it does
If you try to start sending or receiving on a profile that has no password yet, RemSound asks you to set one first (and offers to remember it on the profile so you don't type it again next time). Audio can't flow without a password — encryption is always on, there's no “off” switch.
**Passwords must be reasonably strong (new in 5.6).** The password is the only thing protecting your audio from someone who records your network traffic, so RemSound now refuses to stream on one that's easy to guess: at least 8 characters, and not a famously common password. If your existing password doesn't meet the rule, RemSound tells you the moment you try to stream and walks you through picking a better one — three unrelated words with a number, like `kettle9tiger42moon`, is easy to type and remember and very hard to guess. Set the _same_ new password on every machine you connect with. One honest note: the password is stored in the profile file in a recoverable form (so profiles can sync between your own machines) — anyone who can read your profiles folder can read the passwords, so treat that folder accordingly.
**Use a strong password.** The password is the only thing protecting your audio from someone who records your network traffic, so it's worth picking a good one — three unrelated words with a number, like `kettle9tiger42moon`, is easy to type and remember and very hard to guess. RemSound suggests this when you set a password but doesn't force it, so any password you and the other person agree on will work. One honest note: the password is stored in the profile file in a recoverable form (so profiles can sync between your own machines) — anyone who can read your profiles folder can read the passwords, so treat that folder accordingly.
### When passwords don't match
@@ -1359,7 +1359,7 @@ It is deliberately limited:
Everything lives in the **Service** menu on the menu bar:
1. **Configure service profile …** — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is **Use Windows default audio device, follows Windows changes** — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. The service password follows the same strength rule as the app (from version 5.6): because the service runs in the background with no window, it can't ask you to strengthen a weak one — instead it simply won't stream, and RemSound warns you on its next launch (and when you save the service profile) so you know to change it here. This is a separate profile from your normal ones and does not appear in the usual profile list. The **Additional options** button holds two extras. First, a switch for the service's own log. Second, **Set the machine's volume when the service starts** : tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts — handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The **When** list chooses between _Only the first start after each boot_ and _Every time the service starts_. **First start after boot is the recommended, set-and-forget choice** : it sets the volume once when the machine boots and never touches it again, so it won't fight you while you're using the machine. _Every time the service starts_ re-applies on every service start — useful if you deliberately restart the service to reset the volume, but be aware the service also restarts by itself for routine reasons (installing a RemSound update, saving the service profile), and this mode re-applies on those too. To stop it ever machine-gunning your volume, either mode skips a re-apply if the volume was already set within the last few minutes. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds — it streams silently in the background — so there are no cue options here.)
1. **Configure service profile …** — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is **Use Windows default audio device, follows Windows changes** — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The **Additional options** button holds two extras. First, a switch for the service's own log. Second, **Set the machine's volume when the service starts** : tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts — handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The **When** list chooses between _Only the first start after each boot_ and _Every time the service starts_. **First start after boot is the recommended, set-and-forget choice** : it sets the volume once when the machine boots and never touches it again, so it won't fight you while you're using the machine. _Every time the service starts_ re-applies on every service start — useful if you deliberately restart the service to reset the volume, but be aware the service also restarts by itself for routine reasons (installing a RemSound update, saving the service profile), and this mode re-applies on those too. To stop it ever machine-gunning your volume, either mode skips a re-apply if the volume was already set within the last few minutes. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds — it streams silently in the background — so there are no cue options here.)
2. **Install service** — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. Straight after installing, RemSound asks whether you'd like to **start it now** (otherwise it waits until the next reboot). (When you first install RemSound on a PC, the app installer also offers to set the service up — and start it — for you, so you may have done this already.)
3. **Start service** / **Stop service** — run or halt it now without waiting for a reboot.
4. **Uninstall service** — removes it entirely.
+10 -13
View File
@@ -1,31 +1,28 @@
# RemSound v5.6
# RemSound v5.7
**IMPORTANT: everyone must update.** This version strengthens the encryption maths, so a 5.6 machine **cannot exchange audio with any older RemSound** — until both sides are on 5.6, you'll hear nothing between them. Update every machine you connect with, including any running the background service (it updates itself from the app). Remote volume commands also need both ends on 5.6.
Stronger security, and it works with every version again.
## Stronger passwords, enforced
The profile password is what protects your audio, so from this version RemSound refuses to stream on one that's easy to guess. Passwords must be at least 8 characters and not a common word — if yours is shorter, RemSound tells you the moment you try to stream and walks you through choosing a better one. Three unrelated words with a number, like `kettle9tiger42moon`, is easy to type and very hard to guess. Remember to set the **same** new password on every machine you connect with.
This release puts the password handling back the way it was, so RemSound talks to older versions and the iPhone app again. You still set a password — your audio is always encrypted — RemSound just suggests a strong one now instead of requiring it. If you already use a good password, you won't notice any difference.
## Signed updates
Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us — so even if the download page were ever tampered with, a fake update could not install itself on your machine. (This release ships the checking code; it protects every release from here on.)
Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us — so even if the download page were ever tampered with, a fake update couldn't install itself on your machine.
## Remote volume, now password-locked
## Remote volume, password-protected
The remote volume and mute commands are sealed with your profile password, so only someone who knows it can adjust your machine — nobody on the network can fake a command and mute your screen reader. A captured command can't be replayed later either.
The remote volume and mute controls are locked to your password, so only someone who shares it can use them. (Both ends need 5.6 or newer for the remote-volume feature; ordinary audio works with any version.)
## Set the machine's volume when the service starts
In the service's **Additional options** you can have an unattended machine unmute itself and set its Windows volume to a level you choose — on the first start after each boot, or on every service start.
In the service's Additional options you can have an unattended machine unmute itself and set its Windows volume to a level you choose — on the first start after each boot, or on every service start.
## Updates on your schedule
In Preferences you can restrict automatic updates to a daily time range — say 01:00 to 06:00 — so an update never closes RemSound and kills your sound mid-session. Found outside the range, it quietly waits and installs the moment the range opens. The manual "Check for updates now" button is never restricted.
In Preferences you can restrict automatic updates to a daily time range — say 1am to 6am — so an update never closes RemSound and interrupts you. Found outside the range, it quietly waits and installs the moment the range opens.
## Also in this release
- The app releases its high-priority and keep-awake settings when you're not actually streaming — kinder to laptops left idling in the tray.
- Diagnostic logs cap their own size on long sessions, and old crash reports are tidied automatically.
- The remembered-applications list explains itself when empty (apps join it the moment you tick them).
- The public relay gained anti-abuse protections; a later relay update will require 5.6, which answers its address checks automatically.
- A large amount of security hardening from a full audit: service-folder lockdown, replay protection, counter-based encryption nonces, and more.
- The remembered-applications list explains itself when empty.
- A large amount of behind-the-scenes hardening from a full security audit.
+2 -2
View File
@@ -742,7 +742,7 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<p>If you try to start sending or receiving on a profile that has no password yet, RemSound asks you to set one first (and offers to remember it on the profile so you don't type it again next time). Audio can't flow without a password &mdash; encryption is always on, there's no &ldquo;off&rdquo; switch.</p>
<p><strong>Passwords must be reasonably strong (new in 5.6).</strong> The password is the only thing protecting your audio from someone who records your network traffic, so RemSound now refuses to stream on one that's easy to guess: at least 8 characters, and not a famously common password. If your existing password doesn't meet the rule, RemSound tells you the moment you try to stream and walks you through picking a better one &mdash; three unrelated words with a number, like <code>kettle9tiger42moon</code>, is easy to type and remember and very hard to guess. Set the <em>same</em> new password on every machine you connect with. One honest note: the password is stored in the profile file in a recoverable form (so profiles can sync between your own machines) &mdash; anyone who can read your profiles folder can read the passwords, so treat that folder accordingly.</p>
<p><strong>Use a strong password.</strong> The password is the only thing protecting your audio from someone who records your network traffic, so it's worth picking a good one &mdash; three unrelated words with a number, like <code>kettle9tiger42moon</code>, is easy to type and remember and very hard to guess. RemSound suggests this when you set a password but doesn't force it, so any password you and the other person agree on will work. One honest note: the password is stored in the profile file in a recoverable form (so profiles can sync between your own machines) &mdash; anyone who can read your profiles folder can read the passwords, so treat that folder accordingly.</p>
<h3>When passwords don't match</h3>
@@ -1417,7 +1417,7 @@ RemSound.exe --connect 192.168.1.42
<h3>Setting it up</h3>
<p>Everything lives in the <strong>Service</strong> menu on the menu bar:</p>
<ol>
<li><strong>Configure service profile&hellip;</strong> &mdash; opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is <strong>Use Windows default audio device, follows Windows changes</strong> &mdash; tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no &ldquo;send my audio&rdquo; switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. The service password follows the same strength rule as the app (from version 5.6): because the service runs in the background with no window, it can't ask you to strengthen a weak one &mdash; instead it simply won't stream, and RemSound warns you on its next launch (and when you save the service profile) so you know to change it here. This is a separate profile from your normal ones and does not appear in the usual profile list. The <strong>Additional options</strong> button holds two extras. First, a switch for the service's own log. Second, <strong>Set the machine's volume when the service starts</strong>: tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts &mdash; handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The <strong>When</strong> list chooses between <em>Only the first start after each boot</em> and <em>Every time the service starts</em>. <strong>First start after boot is the recommended, set-and-forget choice</strong>: it sets the volume once when the machine boots and never touches it again, so it won't fight you while you're using the machine. <em>Every time the service starts</em> re-applies on every service start &mdash; useful if you deliberately restart the service to reset the volume, but be aware the service also restarts by itself for routine reasons (installing a RemSound update, saving the service profile), and this mode re-applies on those too. To stop it ever machine-gunning your volume, either mode skips a re-apply if the volume was already set within the last few minutes. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds &mdash; it streams silently in the background &mdash; so there are no cue options here.)</li>
<li><strong>Configure service profile&hellip;</strong> &mdash; opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is <strong>Use Windows default audio device, follows Windows changes</strong> &mdash; tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) There is no &ldquo;send my audio&rdquo; switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The <strong>Additional options</strong> button holds two extras. First, a switch for the service's own log. Second, <strong>Set the machine's volume when the service starts</strong>: tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts &mdash; handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The <strong>When</strong> list chooses between <em>Only the first start after each boot</em> and <em>Every time the service starts</em>. <strong>First start after boot is the recommended, set-and-forget choice</strong>: it sets the volume once when the machine boots and never touches it again, so it won't fight you while you're using the machine. <em>Every time the service starts</em> re-applies on every service start &mdash; useful if you deliberately restart the service to reset the volume, but be aware the service also restarts by itself for routine reasons (installing a RemSound update, saving the service profile), and this mode re-applies on those too. To stop it ever machine-gunning your volume, either mode skips a re-apply if the volume was already set within the last few minutes. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds &mdash; it streams silently in the background &mdash; so there are no cue options here.)</li>
<li><strong>Install service</strong> &mdash; registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. Straight after installing, RemSound asks whether you'd like to <strong>start it now</strong> (otherwise it waits until the next reboot). (When you first install RemSound on a PC, the app installer also offers to set the service up &mdash; and start it &mdash; for you, so you may have done this already.)</li>
<li><strong>Start service</strong> / <strong>Stop service</strong> &mdash; run or halt it now without waiting for a reboot.</li>
<li><strong>Uninstall service</strong> &mdash; removes it entirely.</li>
+7 -7
View File
@@ -20,21 +20,21 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v5.6
RemSound v5.7
IMPORTANT: everyone must update. This version strengthens the encryption maths, so a 5.6 machine cannot exchange audio with any older RemSound until both sides are on 5.6, you'll hear nothing between them. Update every machine you connect with, including any running the background service (it updates itself from the app). Remote volume commands also need both ends on 5.6.
Stronger security, and it works with every version again.
Stronger passwords, enforced. The profile password is what protects your audio, so from this version RemSound refuses to stream on one that's easy to guess. Passwords must be at least 8 characters and not a common word if yours is shorter, RemSound tells you the moment you try to stream and walks you through choosing a better one. Three unrelated words with a number, like kettle9tiger42moon, is easy to type and very hard to guess. Remember to set the SAME new password on every machine you connect with.
This release puts the password handling back the way it was, so RemSound talks to older versions and the iPhone app again. You still set a password your audio is always encrypted we just suggest a strong one now instead of requiring it. Nothing changes for you if you already use a good password.
Signed updates. Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us so even if our download page were ever tampered with, a fake update could not install itself on your machine.
Signed updates. Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us so even if the download page were ever tampered with, a fake update couldn't install itself on your machine.
Remote volume, now password-locked. The remote volume and mute commands are sealed with your profile password, so only someone who knows it can adjust your machine nobody on the network can fake a command and mute your screen reader.
Remote volume, now password-protected. The remote volume and mute controls are locked to your password, so only someone who shares it can use them.
Set the machine's volume when the service starts. In the service's Additional options you can have an unattended machine unmute itself and set its Windows volume to a level you choose on the first start after each boot, or on every service start.
Updates on your schedule. In Preferences you can now restrict automatic updates to a time range say 1am to 6am so an update never closes RemSound and kills your sound mid-session. Found outside the range, it quietly waits and installs the moment the range opens.
Updates on your schedule. In Preferences you can restrict automatic updates to a time range say 1am to 6am so an update never closes RemSound and interrupts you. Found outside the range, it quietly waits and installs the moment the range opens.
Plus: the app now releases its high-priority and keep-awake settings when you're not actually streaming (kinder to laptops), diagnostic logs cap their own size on long sessions, the remembered-applications list explains itself when empty, and a raft of security hardening under the hood.
Plus: the app releases its high-priority and keep-awake settings when you're not actually streaming (kinder to laptops), diagnostic logs cap their own size on long sessions, and a raft of behind-the-scenes hardening.
RemSound v5.5
+23 -163
View File
@@ -1373,10 +1373,6 @@ public sealed partial class MainForm : Form
EvaluatePriorityModeScope();
UpdateStatus();
SnapshotLogIfDue();
// Catch a weak SERVICE password even while the app is ALREADY open (e.g. the service
// auto-updated to 5.6 underneath us) — self-throttled + one-shot, so it costs nothing
// once handled. The service can't warn the user itself (headless, session 0).
MaybeWarnWeakServicePassword();
EnsureRequestedAudioRunning();
// Refresh the Connectivity tab's peer lists from the same 1 Hz tick — replaces
// the dialog's old 1.5 s dedicated refresh timer. Each Sync* helper short-circuits
@@ -1670,83 +1666,8 @@ public sealed partial class MainForm : Form
MaybeWarnAboutRealtekAsio();
if (IsDisposed) return;
MaybeWarnMicBlockedOnStartup();
if (IsDisposed) return;
MaybeWarnWeakPassword();
if (IsDisposed) return;
MaybeWarnWeakServicePassword();
}
/// <summary>The send-only service is headless — it CANNOT ask for a password. So when it
/// auto-updates to 5.6 with a pre-existing weak service-profile password, it silently stops
/// streaming and only a log line explains why (nobody watches a service log). This closes that
/// gap from the one place a user WILL see: the interactive app's launch. If a service is installed
/// and its profile password is too weak, warn here (readable, focus-clean, via the same settled
/// notice sequence as the other startup warnings) and offer to open the service settings to fix it
/// (Ed, 2026-07-27 — "people won't know to change the service password"). Self-resolving: once the
/// service password is strengthened, this never fires again.</summary>
// Warn at most ONCE per app session (launch OR the live poll below), and re-check no more than
// every 15 s when driven from the 1 Hz status tick so we're not reading the service state every
// second. Cleared only by a new app session — once fixed, ServicePasswordNeedsStrengthening goes
// false and it never fires again anyway.
private bool serviceWeakPasswordWarned;
private long lastServicePwCheckTick;
private void MaybeWarnWeakServicePassword()
{
if (IsDisposed || CuePlayer.GloballyMuted || serviceWeakPasswordWarned) return;
var now = Environment.TickCount64;
if (lastServicePwCheckTick != 0 && now - lastServicePwCheckTick < 15_000) return;
lastServicePwCheckTick = now;
try
{
var installed = ServiceControl.Query() is not (ServiceState.NotInstalled or ServiceState.Unknown);
var sp = ServiceStore.LoadProfile();
var pw = string.IsNullOrEmpty(sp?.Password) ? "" : RemSoundCrypto.Deobfuscate(sp!.Password);
if (!ServicePasswordNeedsStrengthening(installed, pw)) return;
}
catch { return; } // service state unreadable (e.g. Win7 without the feature) — nothing to warn about
serviceWeakPasswordWarned = true; // set BEFORE the modal so a re-entrant tick can't stack it
var open = ForegroundDialog.Show(owner => MessageBox.Show(owner,
"Your RemSound background service is using a password that's too weak to meet the new "
+ "security rules, so the service won't stream until the password is changed. (The "
+ "service can't ask you itself, because it runs in the background with no window.)\n\n"
+ "Use at least 8 characters — three unrelated words with a number, like kettle9tiger42moon, works well.\n\n"
+ "Would you like to open the service settings now to change it?",
"RemSound — service password needs strengthening",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning));
if (open == DialogResult.Yes && !IsDisposed) ConfigureServiceProfile();
}
/// <summary>Pure, testable: should the app nag about the SERVICE profile's password? Only when a
/// service is installed AND its password is set-but-weak — the regression case (a service that
/// used to stream goes silent after the 5.6 auto-update). An unset password is a never-configured
/// service, not a regression, so it's left alone here.</summary>
internal static bool ServicePasswordNeedsStrengthening(bool serviceInstalled, string? servicePlainPassword) =>
serviceInstalled && !string.IsNullOrEmpty(servicePlainPassword) && PasswordStrength.Critique(servicePlainPassword) is not null;
/// <summary>Startup warning for a profile whose password is too weak to stream under the 5.6 rule.
/// Runs from the SETTLED post-launch notice sequence — after the window is fully shown and
/// activated, one dialog at a time — NOT from the mid-connect crypto path where an earlier version
/// fired it and left a blind user trapped behind a dialog NVDA couldn't reach (Ed, 2026-07-27).
/// From here <see cref="ForegroundDialog"/> pulls it to the front (even from the tray) with clean
/// focus, exactly like the mic-blocked and Realtek warnings that already work with NVDA. Only fires
/// when the profile is actually set up to stream (send or receive on) — otherwise there's no audio
/// to block and the guided prompt on the first streaming tick is enough. The status line carries a
/// standing reminder after this is dismissed.</summary>
private void MaybeWarnWeakPassword()
{
if (IsDisposed || CuePlayer.GloballyMuted) return; // never on a --silent/automated launch
if (!WeakPasswordBlocksAudio(currentProfilePassword, currentAudioKey is not null)) return;
if (!IsSendEnabled && !IsReceiveEnabled) return; // not trying to stream → nothing blocked yet
ForegroundDialog.Show(owner => MessageBox.Show(owner,
"RemSound has increased its security level, so this profile's password must be strengthened "
+ "to meet the new password rules. Until it is, no audio will pass.\n\n"
+ "Use at least 8 characters — three unrelated words with a number, like kettle9tiger42moon, works well.\n\n"
+ "To change it: open the File menu and choose “Change this profile's password”. Use the "
+ "same new password on every machine you connect with.",
"RemSound — password needs strengthening",
MessageBoxButtons.OK, MessageBoxIcon.Warning));
}
/// <summary>Startup log-folder housekeeping driven by the Logging-tab preferences. Both steps are
/// opt-in (off by default): first prune logs older than the configured age, then warn if the
@@ -7272,12 +7193,7 @@ public sealed partial class MainForm : Form
: "not receiving";
var peerCount = knownPeers.Count;
var hbSummary = heartbeatService?.GetHealthSummary() ?? "no peers";
// 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}.";
statusLabel.Text = $"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"
@@ -8365,18 +8281,13 @@ public sealed partial class MainForm : Form
}
}
/// <summary>Derive the audio key + fingerprint from the current profile password and push them
/// to the sender and receiver. No/weak password → null key → no audio flows (encryption is
/// mandatory). Called on password change, when audio is (re)configured, and on the streaming
/// gate. 2026-05-31.
///
/// The 5.6 PBKDF2 raise (600k, run twice) costs up to ~1 s on old hardware, so the FIRST use of
/// a strong password this session is derived OFF the UI thread — otherwise a blind user gets a
/// full NVDA freeze on profile load / first connect (2026-07-27 review). The key is held null
/// until it lands (audio simply waits — mandatory encryption means it never streams keyless), a
/// generation guard drops a stale result if the password changed again meanwhile, and the fast
/// cases (unchanged / cached / empty / weak — no PBKDF2) still apply synchronously so nothing
/// races on the common path.</summary>
/// <summary>Derive the audio key + fingerprint from the current profile password and push them to
/// the sender and receiver. No password → null key → no audio (encryption is mandatory — a
/// password is still required). ANY non-empty password derives a key; strength is not enforced
/// (v5.7 backed out the 5.6 block so the other ports work again). The FIRST use of a password this
/// session is derived OFF the UI thread so the key derivation never freezes NVDA on profile load /
/// first connect; the key is held null until it lands (audio just waits — never streams keyless),
/// and a generation guard drops a stale result if the password changed again meanwhile.</summary>
private void RecomputeAudioCrypto()
{
var pw = currentProfilePassword;
@@ -8387,31 +8298,27 @@ public sealed partial class MainForm : Form
}
lastDerivedPassword = pw;
var gen = ++cryptoGeneration;
// The password genuinely changed — re-arm the one-shot weak-password explanation so a SECOND
// weak-password profile switched to in the same session is still explained (not just the first).
weakPasswordExplained = false;
// Fast path: null/empty/weak (resolves to (null,null) with no PBKDF2), or an already-cached
// strong password. Apply synchronously — no freeze possible. Also covers the pre-window
// case where the form has no handle yet (BeginInvoke would throw), so startup stays correct.
// Fast path: empty (no key, no PBKDF2) or an already-cached password. Apply synchronously —
// no freeze possible. Also covers the pre-window case where the form has no handle yet
// (BeginInvoke would throw), so startup stays correct.
if (RemSoundCrypto.IsCached(pw) || !IsHandleCreated)
{
(currentAudioKey, currentAudioFingerprint) = RemSoundCrypto.ForPlainPassword(pw);
PushAudioCrypto();
ExplainWeakPasswordIfNeeded(pw);
return;
}
// Slow path: a strong password not yet derived this session. Hold the key null (audio waits)
// and do the PBKDF2 on a worker; apply on the UI thread when it lands, unless superseded.
// Slow path: a password not yet derived this session. Hold the key null (audio waits) and do
// the PBKDF2 on a worker; apply on the UI thread when it lands, unless superseded.
currentAudioKey = null;
currentAudioFingerprint = null;
PushAudioCrypto();
logFile.Event("audio crypto: deriving key off-thread (strong password, first use this session)");
logFile.Event("audio crypto: deriving key off-thread (first use this session)");
var pwLocal = pw;
Task.Run(() =>
{
RemSoundCrypto.Prewarm(pwLocal); // the ~1 s PBKDF2, off the UI thread
RemSoundCrypto.Prewarm(pwLocal); // the PBKDF2, off the UI thread
try
{
BeginInvoke(() =>
@@ -8434,69 +8341,22 @@ public sealed partial class MainForm : Form
receiver.AudioFingerprint = currentAudioFingerprint;
}
/// <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 (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;
// One-shot flag for the weak-password explanation — the dialog must not re-fire on every profile
// reapply within a session (the log line still records each derivation refusal). Reset on a
// profile switch so a SECOND weak-password profile in one session is still explained.
private bool weakPasswordExplained;
/// <summary>The "you need a password before any audio can flow" gate. Called when the user
/// ticks Send my audio or Receive audio. If the active profile has no password, prompt for
/// one. Since 5.6 (Ed, 2026-07-27) an EXISTING password that fails the strength rule is gated
/// the same way: the user is told why, in plain words, and audio waits until a stronger one is
/// set — grandfathering weak passwords forever would have made the derivation-cost raise
/// theatre, and everyone is already coordinating a password-compatible update in this release.
/// If they give an acceptable password, set it (and offer to save it to the profile); if they
/// cancel, un-tick the box. Returns true if streaming may proceed.</summary>
/// <summary>The "you need a password before any audio can flow" gate. Called when the user ticks
/// Send my audio or Receive audio. Encryption is mandatory, so if the active profile has no
/// password we prompt for one; ANY password is accepted (strength is only suggested, not enforced
/// — v5.7). If they give one, set it (and offer to save it to the profile); if they cancel,
/// un-tick the box. Returns true if streaming may proceed.</summary>
private bool EnsureStreamingPassword(AccessibleCheckBox box)
{
if (!box.Checked) return true; // turning OFF never needs a password
var weakAdvice = PasswordStrength.Critique(currentProfilePassword ?? "");
if (!string.IsNullOrEmpty(currentProfilePassword) && weakAdvice is null) return true; // have one and it passes
if (!box.Checked) return true; // turning OFF never needs a password
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one — good to go
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
if (weakAdvice is not null && !string.IsNullOrEmpty(currentProfilePassword))
{
// Tell the user WHY the password prompt is about to appear with their old password in
// it — a bare dialog would read as a bug to someone whose password worked yesterday.
var page = new TaskDialogPage
{
Caption = "RemSound — password needs strengthening",
Heading = "Your password must be strengthened",
Text = "RemSound has increased its security level, so this profile's password must be "
+ $"strengthened before audio can flow. {weakAdvice}",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page));
}
var entered = ProfilePasswordDialog.Show(label, currentProfilePassword ?? "", requireNonEmpty: true, requireStrong: true);
var entered = ProfilePasswordDialog.Show(label, currentProfilePassword ?? "", requireNonEmpty: true);
if (string.IsNullOrEmpty(entered))
{
// No acceptable password → can't stream. Put the box back without re-firing this gate.
+8 -38
View File
@@ -13,24 +13,9 @@ namespace RemSound.App;
/// </summary>
internal static class ProfilePasswordDialog
{
/// <summary>The pure "should this password entry be rejected, and why" decision, shared by both
/// password dialogs and unit-testable without any UI (2026-07-27). Returns the plain-English
/// advice to show, or null to accept. Rules: an empty entry is not judged here (the
/// requireNonEmpty gate owns that); a CHANGED entry is always judged; an UNCHANGED entry is
/// exempt UNLESS <paramref name="requireStrong"/> — the streaming gate's mode, where the whole
/// point is that the current password already failed the rule, so re-entering it must be
/// refused. Both sides are compared trimmed (fixes the App-review trim inconsistency).</summary>
internal static string? RejectionAdviceFor(string entered, string current, bool requireStrong)
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
{
entered = entered.Trim();
if (entered.Length == 0) return null;
if (!requireStrong && string.Equals(entered, current.Trim(), StringComparison.Ordinal)) return null;
return RemSound.Core.PasswordStrength.Critique(entered);
}
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false)
{
var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty, requireStrong);
var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty);
using (dialog)
{
// Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
@@ -45,7 +30,7 @@ internal static class ProfilePasswordDialog
/// <summary>Construction split from ShowDialog so the accessibility audit can inspect the real
/// dialog (inline-built dialogs used to be invisible to the audit).</summary>
internal static (Form Dialog, TextBox Input) Build(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false)
internal static (Form Dialog, TextBox Input) Build(string profileTitle, string currentPassword, bool requireNonEmpty = false)
{
var dialog = new Form
{
@@ -75,7 +60,9 @@ internal static class ProfilePasswordDialog
};
var hint = new Label
{
Text = "Both you and the person you're connecting to must use the same password.",
// Suggest a strong password, but never enforce it (v5.7 backed out the block).
Text = "Both you and the person you're connecting to must use the same password. "
+ "A longer one — three unrelated words with a number, like kettle9tiger42moon — is best.",
AutoSize = true,
};
@@ -107,25 +94,8 @@ internal static class ProfilePasswordDialog
textBox.SelectAll();
return;
}
// Strength gate (2026-07-27) — the decision lives in the pure RejectionAdviceFor so a
// test can pin it without a modal dialog (and both password dialogs share one rule).
if (RejectionAdviceFor(entered, currentPassword, requireStrong) is { } advice)
{
var page = new TaskDialogPage
{
Caption = "Choose a stronger password",
Heading = "That password is too easy to guess",
Text = advice,
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
TaskDialog.ShowDialog(dialog, page);
textBox.Focus();
textBox.SelectAll();
return;
}
// Any non-empty password is accepted — strength is only suggested (in the hint), never
// enforced (v5.7 backed out the 5.6 block so the other ports work again).
dialog.DialogResult = DialogResult.OK;
dialog.Close();
}
@@ -78,37 +78,7 @@ internal static class ProfilePasswordManagerDialog
rows.Add((title, current, box));
}
var okButton = new Button { Text = "&OK", AutoSize = true };
// OK validates by hand (no auto-close DialogResult): every CHANGED, non-empty entry passes
// the same strength gate as the single-password dialog — one rule at every door. Unchanged
// entries always pass (an old weak password is grandfathered until the day it's changed).
okButton.Click += (_, _) =>
{
foreach (var (title, original, box) in rows)
{
// Same shared decision as the single-password dialog (casual mode: unchanged is
// exempt, changed-and-weak is refused) — one rule at every door, compared trimmed.
if (ProfilePasswordDialog.RejectionAdviceFor(box.Text, original, requireStrong: false) is { } advice)
{
var page = new TaskDialogPage
{
Caption = "Choose a stronger password",
Heading = $"The new password for “{title}” is too easy to guess",
Text = advice,
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
TaskDialog.ShowDialog(dialog, page);
box.Focus();
box.SelectAll();
return;
}
}
dialog.DialogResult = DialogResult.OK;
dialog.Close();
};
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK };
var cancelButton = new Button { Text = "&Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, FlowDirection = FlowDirection.RightToLeft, AutoSize = true, Padding = new Padding(8) };
buttons.Controls.Add(okButton);
+1 -1
View File
@@ -18,7 +18,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>5.6</Version>
<Version>5.7</Version>
</PropertyGroup>
<ItemGroup>
+26 -74
View File
@@ -130,8 +130,7 @@ internal static class SelfTest
RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow);
RunStep(results, "Release signing (verify, tamper, key-embed match)", ReleaseSigning);
RunStep(results, "Updater refuses an unsigned or badly-signed release (enforcement flow)", UpdaterRefusesUnsignedRelease);
RunStep(results, "Streaming password strengthening (existing weak password forced up)", StreamingPasswordStrengthening);
RunStep(results, "Password strength rules (gate + derivation refusal)", PasswordRules);
RunStep(results, "Password derivation (any password derives a key; cached; 100k for cross-port compat)", PasswordDerivation);
RunStep(results, "Relay address-proof echo (AddrCheck round-trip)", RelayAddrCheckEcho);
var failed = results.Count(r => r.Status == "FAIL");
@@ -2498,85 +2497,38 @@ internal static class SelfTest
return "no-sig + wrong-key + garbage + tamper all refused; a genuine release accepted";
}
/// <summary>The streaming password-strengthening walk-through (2026-07-27): an EXISTING weak
/// password must be forced up before audio flows — the load-bearing bit is that the streaming
/// prompt runs with requireStrong, which DISABLES the "unchanged password is exempt" rule, so
/// re-entering the same weak password is refused. Pins the dialog decision the pure Critique
/// test can't see.</summary>
private static string? StreamingPasswordStrengthening()
/// <summary>Password → key derivation after the v5.7 back-out. Encryption stays mandatory (a
/// password is required — empty derives nothing), but strength is NOT enforced: ANY non-empty
/// password derives its key + fingerprint, weak OR strong, so the other ports (iOS / older
/// desktops on the same 100k derivation) can exchange audio again. The session cache still
/// returns the same instances on a repeat derive. Pins the 100k iteration count via a known
/// answer test, so a future accidental change (which would silently break cross-port compat)
/// fails the gate.</summary>
private static string? PasswordDerivation()
{
// Streaming mode (requireStrong: true) — the exemption is DISABLED, so re-entering the same
// weak password is refused and only a strong replacement is accepted. This is the bit that,
// if it regressed to the casual rule, would let "Games" keep streaming and defeat the whole
// 5.6 password raise.
Check(ProfilePasswordDialog.RejectionAdviceFor("Games", current: "Games", requireStrong: true) is not null,
"streaming mode must REFUSE re-entering the same weak password (no unchanged-exemption)");
Check(ProfilePasswordDialog.RejectionAdviceFor("kettle9tiger42moon", current: "Games", requireStrong: true) is null,
"a strong replacement must be accepted in streaming mode");
// Casual mode (requireStrong: false) — an UNCHANGED existing password is grandfathered (a
// visit that doesn't touch it must not trap the user behind the new rule)...
Check(ProfilePasswordDialog.RejectionAdviceFor("Games", current: "Games", requireStrong: false) is null,
"casual mode must let an UNCHANGED existing password through");
// ...but a NEW weak password is still refused, and trailing whitespace doesn't fool the
// unchanged comparison (both sides trimmed — the App-review inconsistency is gone).
Check(ProfilePasswordDialog.RejectionAdviceFor("Games", current: "kettle9tiger42moon", requireStrong: false) is not null,
"casual mode must still block a NEW weak password");
Check(ProfilePasswordDialog.RejectionAdviceFor(" Games ", current: "Games", requireStrong: false) is null,
"the unchanged-exemption must compare trimmed (whitespace-only edit is still 'unchanged')");
return "requireStrong refuses an unchanged weak password; casual grandfathers unchanged but blocks new-weak; trim-safe";
}
/// <summary>The 5.6 password rules: the strength critique (what the dialogs enforce and
/// explain) and the derivation choke-point refusing weak passwords outright, so NO path —
/// tick, startup auto-connect, headless service — streams on a guessable password.</summary>
private static string? PasswordRules()
{
Check(PasswordStrength.Critique("") is null, "empty is not critiqued here (clearing has its own gate)");
Check(PasswordStrength.Critique("Games") is not null, "a 5-character password must be rejected (the exact case that prompted this)");
Check(PasswordStrength.Critique("Password1") is not null, "a world's-most-common password must be rejected regardless of case");
Check(PasswordStrength.Critique("kettle9tiger42moon") is null, "a three-words-and-numbers passphrase must pass");
Check(PasswordStrength.Critique("hunter2horse42stable") is null, "the test-suite passphrase must pass");
var advice = PasswordStrength.Critique("short") ?? "";
Check(advice.Contains("at least 8", StringComparison.OrdinalIgnoreCase) && advice.Contains("kettle9tiger42moon"),
"the critique must say the rule AND give a concrete example to copy the shape of");
// Mandatory encryption: no password → no key. Any non-empty password → a full key + fingerprint.
Check(RemSoundCrypto.ForPlainPassword("") is (null, null), "no password must derive no key (encryption is mandatory)");
var (weakKey, weakFp) = RemSoundCrypto.ForPlainPassword("Games");
Check(weakKey is null && weakFp is null, "the shared derivation rule must refuse a weak password — no key, no audio, on every path");
var (goodKey, goodFp) = RemSoundCrypto.ForPlainPassword("kettle9tiger42moon");
Check(goodKey is { Length: 32 } && goodFp is { Length: 8 }, "a strong password must derive the full key + fingerprint");
Check(weakKey is { Length: 32 } && weakFp is { Length: 8 }, "a WEAK password must still derive a key now (strength is not enforced — v5.7 back-out)");
var (strongKey, strongFp) = RemSoundCrypto.ForPlainPassword("kettle9tiger42moon");
Check(strongKey is { Length: 32 } && strongFp is { Length: 8 }, "a strong password derives a key too");
Check(!weakKey.AsSpan().SequenceEqual(strongKey), "different passwords must derive different keys");
// Session cache (the NVDA-hang fix): a distinct strong password is a cache MISS until derived
// or prewarmed, then a HIT — so the ~1s PBKDF2 runs once per password per process and the
// UI-thread path can stay synchronous only when it's a hit. A second derive returns the SAME
// arrays (proof it wasn't recomputed). Empty/weak always count as "no work" (never block).
Check(RemSoundCrypto.IsCached("kettle9tiger42moon"), "a password just derived must read back as cached");
// Session cache: a repeat derive returns the SAME arrays (not recomputed).
var again = RemSoundCrypto.ForPlainPassword("kettle9tiger42moon");
Check(ReferenceEquals(again.Key, goodKey), "a cached derive must return the same key instance, not recompute it");
var fresh = "prewarm7melon42anchor";
Check(!RemSoundCrypto.IsCached(fresh), "an unused strong password must read as a cache miss (would block the UI thread)");
Check(ReferenceEquals(again.Key, strongKey), "a cached derive must return the same key instance, not recompute it");
var fresh = "melon42anchor";
Check(!RemSoundCrypto.IsCached(fresh), "an unused password must read as a cache miss");
RemSoundCrypto.Prewarm(fresh);
Check(RemSoundCrypto.IsCached(fresh), "Prewarm must derive off-thread so the later UI-thread lookup is an instant hit");
Check(RemSoundCrypto.IsCached("") && RemSoundCrypto.IsCached("Games"),
"empty and weak passwords must count as 'no work' (they never trigger a blocking derive)");
Check(RemSoundCrypto.IsCached(fresh), "Prewarm must populate the cache");
// 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");
// The iteration count is part of the key recipe and MUST stay 100k or the other ports (iOS /
// older desktops) derive a different key from the same password. Pin it directly so an
// accidental change fails the gate loudly instead of silently breaking cross-port compat.
Check(RemSoundCrypto.Pbkdf2Iterations == 100_000,
$"PBKDF2 iterations must stay 100k for cross-port compatibility — got {RemSoundCrypto.Pbkdf2Iterations}");
// The headless service can't ask for a password, so the app nags on launch when an INSTALLED
// service has a set-but-weak password (the silent-death-after-auto-update case, Ed 2026-07-27).
Check(MainForm.ServicePasswordNeedsStrengthening(serviceInstalled: true, "Games"), "installed service + weak password → the app must warn");
Check(!MainForm.ServicePasswordNeedsStrengthening(serviceInstalled: false, "Games"), "no service installed → nothing to warn about");
Check(!MainForm.ServicePasswordNeedsStrengthening(serviceInstalled: true, ""), "an unset service password is 'not configured', not a weak-password regression");
Check(!MainForm.ServicePasswordNeedsStrengthening(serviceInstalled: true, "kettle9tiger42moon"), "a strong service password needs no nag");
return "weak+common refused with advice; derivation refuses weak everywhere; cached; app+service weak surfaced";
return "empty→no key; any non-empty (weak or strong)→key; cache reuses instances; 100k iteration count pinned";
}
/// <summary>The relay address-proof (2026-07-27): an AddrCheck cookie arriving at the receiver
+2 -36
View File
@@ -42,9 +42,7 @@ internal sealed class ServiceProfileDialog : Form
private const SendRate ServiceSendRate = ServiceAudioDefaults.Rate;
// --- Button row ---
// No auto-close DialogResult: the Click handler validates the password strength first (a weak
// service password means the headless service silently won't stream), then closes explicitly.
private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true };
private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true, DialogResult = DialogResult.OK };
private readonly Button cancelButton = new() { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
private readonly Button additionalButton = new() { Text = "Additional &options...", AutoSize = true, AccessibleName = "Additional options" };
@@ -82,13 +80,7 @@ internal sealed class ServiceProfileDialog : Form
AcceptButton = saveButton;
CancelButton = cancelButton;
saveButton.Click += (_, _) =>
{
if (!ConfirmOrFixWeakPassword()) return; // user chose to keep editing / cancel the fix
SaveToProfile();
DialogResult = DialogResult.OK;
Close();
};
saveButton.Click += (_, _) => SaveToProfile();
additionalButton.Click += (_, _) => ShowAdditionalOptions();
}
@@ -337,32 +329,6 @@ internal sealed class ServiceProfileDialog : Form
UpdatePasswordStatus();
}
/// <summary>On Save: if the service password is too weak the headless service will silently not
/// stream, so warn and offer to strengthen it right now. Returns true to proceed with the save
/// (either the password is fine, was just strengthened, or the user chose to save anyway), false
/// to stay in the dialog. Empty is left alone — that's an un-configured profile, not a regression.</summary>
private bool ConfirmOrFixWeakPassword()
{
var current = string.IsNullOrEmpty(working.Password) ? "" : RemSoundCrypto.Deobfuscate(working.Password);
if (string.IsNullOrEmpty(current) || PasswordStrength.Critique(current) is null) return true;
var choice = ForegroundDialog.Show(owner => MessageBox.Show(owner,
"This service password is too weak to meet the new security rules, so the service won't "
+ "stream until it's strengthened.\n\n"
+ "Use at least 8 characters — three unrelated words with a number, like kettle9tiger42moon, works well.\n\n"
+ "Set a stronger password now?",
"RemSound — service password needs strengthening",
MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning));
if (choice == DialogResult.Cancel) return false; // back to the dialog, nothing saved
if (choice == DialogResult.No) return true; // save the weak one anyway (warned)
// Yes → enter a stronger one now (requireStrong disables the unchanged-exemption).
var stronger = ProfilePasswordDialog.Show(ServiceControl.ServiceProfileTitle, current, requireNonEmpty: true, requireStrong: true);
if (string.IsNullOrEmpty(stronger)) return false; // cancelled the change — stay put
working.Password = RemSoundCrypto.Obfuscate(stronger);
UpdatePasswordStatus();
return true;
}
private void UpdatePasswordStatus()
=> passwordStatus.Text = string.IsNullOrEmpty(working.Password) ? "No password set." : "Password set.";
-5
View File
@@ -329,11 +329,6 @@ public sealed class ServiceSendHost : IDisposable
// verifies the fingerprint before accepting a stream; a key alone gets silently rejected).
var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password);
(sender.AudioKey, sender.AudioFingerprint) = RemSoundCrypto.ForPlainPassword(plainPassword);
// Since 5.6 the shared rule also refuses a WEAK password (key null → nothing sent). The
// headless service can't pop a dialog, so the log must carry the why — otherwise this
// reads as the old "no password set" and sends someone hunting the wrong bug.
if (sender.AudioKey is null && plainPassword.Length > 0)
log?.Invoke("service: the service profile's password fails the 5.6 strength rule — no audio until it's changed (Service menu, Configure service profile, Set service profile password)");
// The service's audio transport is FIXED to the known-good live-jamming config, regardless of
// what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17).
// The numbers live in ServiceAudioDefaults, shared with the dialog that writes the profile.
-42
View File
@@ -1,42 +0,0 @@
namespace RemSound.Core;
/// <summary>
/// The gate for NEW profile passwords (2026-07-27, alongside the PBKDF2 raise). The password is the
/// ONLY thing standing between a captured stream and an offline guessing rig — the fingerprint
/// travels in cleartext, so a short or common password falls in seconds no matter how slow we make
/// the derivation. Deliberately simple and predictable (no scoring meter — a screen-reader user
/// gets one clear rule and one concrete suggestion): at least <see cref="MinLength"/> characters
/// and not an infamous password. Existing saved passwords are grandfathered — the gate fires only
/// when a password is being SET or CHANGED, so nobody's working setup breaks; they meet the rule
/// the next time they choose to change it.
/// </summary>
public static class PasswordStrength
{
public const int MinLength = 8;
// The classics that appear at the top of every breached-password list. Not a dictionary —
// just the entries so common that allowing them makes the length rule meaningless.
private static readonly string[] CommonPasswords =
{
"password", "password1", "12345678", "123456789", "1234567890", "qwertyui", "qwerty123",
"11111111", "iloveyou", "sunshine", "letmein1", "trustno1", "remsound",
};
/// <summary>Null when the password is acceptable; otherwise ONE plain-English paragraph
/// telling the user exactly what to do instead. Empty input returns null — clearing a
/// password is a separate, deliberate act with its own gate.</summary>
public static string? Critique(string password)
{
if (string.IsNullOrEmpty(password)) return null;
// Kept short on purpose (Ed, 2026-07-27): one line — the rule and a concrete example to copy
// the shape of. The surrounding dialogs add the "why" and the "how to change it".
if (password.Length < MinLength)
return $"Use at least {MinLength} characters — three unrelated words with a number, like kettle9tiger42moon, works well.";
foreach (var common in CommonPasswords)
{
if (string.Equals(password, common, StringComparison.OrdinalIgnoreCase))
return $"That's one of the most common passwords in the world. Use at least {MinLength} characters — three unrelated words with a number, like kettle9tiger42moon, works well.";
}
return null;
}
}
+21 -29
View File
@@ -52,14 +52,13 @@ public static class RemSoundCrypto
private const int NonceBytes = 12; // AES-GCM standard nonce
private const int TagBytes = 16; // AES-GCM auth tag
// PBKDF2 cost. Raised 100k → 600k for v5.6 (2026-07-27, per the security audit — 100k was
// well below current OWASP guidance and the fingerprint travels in cleartext, so offline
// guessing cost is the whole defence). BREAKING: both peers must derive the SAME key from
// the same password, so a 5.6 machine cannot exchange audio with a pre-5.6 machine AT ALL —
// the release notes lead with "everyone must update". Runs once per password and is cached
// (never per packet); ~a few hundred ms even on old hardware, felt only when a password is
// set or a profile loads.
private const int Pbkdf2Iterations = 600_000;
// PBKDF2 cost. MUST stay 100k: this is part of the key recipe, so every RemSound "port" (the
// Windows app + service, the iOS app, any other build) has to use the same count or the SAME
// password produces a DIFFERENT key and audio won't decrypt between them. v5.6 briefly raised it
// to 600k, which silently broke cross-port compatibility (iOS/older desktops) even with matching
// passwords; v5.7 put it back to 100k so the other ports work again (Ed, 2026-07-27). Run once
// per password and cached — never per packet.
internal const int Pbkdf2Iterations = 100_000;
// Fixed salts. A per-connection random salt would be stronger, but both peers must derive
// the SAME key from the SAME password with no key-exchange round, so the salt has to be
@@ -71,39 +70,33 @@ public static class RemSoundCrypto
private static readonly byte[] ObfuscationKey =
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
// Session cache of derived credentials, keyed by plaintext password. The 600k-iteration PBKDF2
// (run TWICE per derive, for key + fingerprint) costs up to ~1 s on old hardware; caching means
// a given password pays that ONCE per process, so a profile SWITCH back to a used password — or
// the service re-deriving on an audio-device hot-plug — is instant. The derived key already lives
// in process memory (currentAudioKey), so this doesn't widen exposure; it's cleared on exit like
// everything else. Bounded: a user has a handful of distinct passwords, not thousands.
// Session cache of derived credentials, keyed by plaintext password. Caching means a given
// password pays the PBKDF2 cost ONCE per process, so a profile SWITCH back to a used password —
// or the service re-deriving on an audio-device hot-plug — is instant. The derived key already
// lives in process memory (currentAudioKey), so this doesn't widen exposure; cleared on exit.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (byte[] Key, byte[] Fingerprint)> derivedCache = new(StringComparer.Ordinal);
/// <summary>The one rule for turning a PLAIN password into the audio credentials: null/empty →
/// (null, null) → no audio flows (encryption is mandatory); since 5.6 a password that fails
/// <see cref="PasswordStrength.Critique"/> ALSO yields (null, null) — enforced here, at the single
/// choke-point the app AND the service both derive through, so no path (tick, startup auto-connect,
/// profile switch, headless service) can stream on a guessable password (Ed, 2026-07-27; the UI
/// explains and walks the user to a stronger one). Otherwise the key AND the fingerprint, always
/// together — the peer verifies the fingerprint before accepting a stream, so a key without its
/// fingerprint gets the audio silently rejected at the far end (a divergence that already bit
/// the service once). Result is cached per password (see <see cref="derivedCache"/>); the slow
/// PBKDF2 runs once per distinct password per process. Callers that must not stall the UI thread
/// should <see cref="Prewarm"/> off-thread first (the app does).</summary>
/// (null, null) → no audio flows (encryption is mandatory — a password is still required). ANY
/// non-empty password derives its key + fingerprint; strength is NOT enforced here (v5.7 backed
/// out the 5.6 weak-password block so the other ports work again — Ed, 2026-07-27; the UI just
/// suggests a strong one). Key and fingerprint always come together — the peer verifies the
/// fingerprint before accepting a stream, so a key without its fingerprint gets the audio silently
/// rejected at the far end. Cached per password; the PBKDF2 runs once per distinct password per
/// process. Callers that must not stall the UI thread should <see cref="Prewarm"/> off-thread.</summary>
public static (byte[]? Key, byte[]? Fingerprint) ForPlainPassword(string? plainPassword)
{
if (string.IsNullOrEmpty(plainPassword) || PasswordStrength.Critique(plainPassword) is not null)
return (null, null);
if (string.IsNullOrEmpty(plainPassword)) return (null, null);
var (k, f) = derivedCache.GetOrAdd(plainPassword, static pw => (DeriveKey(pw), Fingerprint(pw)));
return (k, f);
}
/// <summary>Derive-and-cache a password's credentials WITHOUT returning them — for calling on a
/// background thread so the subsequent <see cref="ForPlainPassword"/> on the UI thread is a cache
/// hit and never blocks on PBKDF2. Null/empty/weak passwords are a no-op (nothing to warm).</summary>
/// hit and never blocks on PBKDF2. Null/empty is a no-op (nothing to warm).</summary>
public static void Prewarm(string? plainPassword)
{
if (!string.IsNullOrEmpty(plainPassword) && PasswordStrength.Critique(plainPassword) is null)
if (!string.IsNullOrEmpty(plainPassword))
derivedCache.GetOrAdd(plainPassword, static pw => (DeriveKey(pw), Fingerprint(pw)));
}
@@ -113,7 +106,6 @@ public static class RemSoundCrypto
/// off-thread (a strong password's first use this session). Empty/weak count as "no work".</summary>
public static bool IsCached(string? plainPassword) =>
string.IsNullOrEmpty(plainPassword)
|| PasswordStrength.Critique(plainPassword) is not null
|| derivedCache.ContainsKey(plainPassword);
/// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary>