v5.6 batch: signed releases + stronger passwords enforced + relay address-proof

The everyone-must-update release. Four coordinated changes, each from the security
discussion Ed approved 2026-07-27, plus the remembered-apps polish:

1. SIGNED RELEASES. build-release.ps1 now signs the release zip (ECDSA P-256 /
   SHA-256, --sign-update verb) with a private key that lives ONLY at Ed's chosen
   location outside the repo; the matching public key is embedded (UpdateSignature)
   and the updater REFUSES any release whose .sig asset is missing or does not
   verify - a compromised GitHub account can no longer ship code to users. The
   signing verb self-checks against the embedded key so a key/embed mismatch fails
   the pipeline, and the gate proves the on-disk key matches the embed when present.

2. STRONGER PASSWORDS, ENFORCED (BREAKING). PBKDF2 raised 100k -> 600k (both peers
   must derive the same key, so 5.6 cannot stream with pre-5.6 AT ALL - release
   notes lead with it). New PasswordStrength rule (>= 8 chars, not an infamous
   password) enforced at EVERY door: both password dialogs block weak NEW entries
   with concrete plain-English advice; the streaming gate walks an existing weak
   password through strengthening; and ForPlainPassword - the single derivation
   choke-point shared with the service - refuses weak outright, so no path streams
   on a guessable password. Headless service logs the why. Per Ed: painful once,
   and this coordinated-update release is the cheapest moment it will ever have.

3. RELAY ADDRESS-PROOF (watch-only). The relay sends every new client address a
   random cookie and marks it verified when echoed - a forged source address can
   never echo, killing the reflection attack. 5.6 clients echo automatically
   (AddrCheck type 10, verbatim, self-limiting); the relay ships watch-only
   (logs would-blocks) until the fleet updates, then one flag (--require-addr-check)
   enforces. Per-IP entry cap (4) enforced immediately. Relay changes are committed
   but NOT deployed to the Pi - they ride the v5.6 release moment.

4. Remembered-apps empty state teaches its lifecycle + manual sentence; About/
   release notes written; version bumped to 5.6.

New gate steps: signing round-trip/tamper/wrong-key/embed-match; password rules incl.
the exact "Games" case; AddrCheck verbatim echo. Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-27 08:19:20 +01:00
co-authored by Claude Fable 5
parent 9574d9d08b
commit 6c53fe54d1
18 changed files with 658 additions and 51 deletions
+16
View File
@@ -20,6 +20,22 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v5.6
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 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.
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.
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.
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.
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.
RemSound v5.5
A close-hang fix, and keyboard shortcuts everywhere.
+48
View File
@@ -94,6 +94,10 @@ internal static class CommandLine
return WithConsole(() => SetLogging(ValueAfter(args, raw)));
case "--close": case "--quit":
return WithConsole(CloseRunning);
case "--sign-update":
// Publish-pipeline verb (build-release.ps1): sign a release zip with the private
// key so the updater's signature enforcement accepts it. Not a user command.
return WithConsole(() => SignUpdate(ValueAfter(args, raw)));
}
}
@@ -117,6 +121,50 @@ internal static class CommandLine
return null;
}
/// <summary>Where the release-signing PRIVATE key lives on the publisher's machine (chosen by
/// Ed, 2026-07-27). Overridable via REMSOUND_SIGNING_KEY for a future move. The key is never
/// in the repo or a release; the matching public key is embedded (UpdateSignature).</summary>
private static string SigningKeyPath =>
Environment.GetEnvironmentVariable("REMSOUND_SIGNING_KEY")
?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem";
/// <summary>--sign-update &lt;zip&gt;: write &lt;zip&gt;.sig (base64 ECDSA P-256 / SHA-256 over the
/// zip bytes) and self-check it against the EMBEDDED public key before reporting success — so a
/// key/embed mismatch fails the publish pipeline loudly instead of shipping a release every
/// updater would refuse.</summary>
private static int SignUpdate(string? zipPath)
{
if (string.IsNullOrWhiteSpace(zipPath) || !File.Exists(zipPath))
{
Console.WriteLine($"sign-update: zip not found: [{zipPath}]");
return 2;
}
if (!File.Exists(SigningKeyPath))
{
Console.WriteLine($"sign-update: signing key not found at [{SigningKeyPath}] (set REMSOUND_SIGNING_KEY to override)");
return 3;
}
try
{
var bytes = File.ReadAllBytes(zipPath);
var signature = UpdateSignature.SignWithKey(bytes, File.ReadAllText(SigningKeyPath));
if (!UpdateSignature.Verify(bytes, signature))
{
Console.WriteLine("sign-update: FAILED self-check — the private key does not match the public key embedded in this build. Update UpdateSignature.PublicKeyPem or restore the right key file.");
return 4;
}
var sigPath = zipPath + UpdateSignature.SignatureAssetSuffix;
File.WriteAllText(sigPath, signature);
Console.WriteLine($"sign-update: OK — wrote {sigPath} (verified against the embedded public key)");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"sign-update: FAILED — {ex.GetType().Name}: {ex.Message}");
return 5;
}
}
// ---------------- console plumbing ----------------
/// <summary>Attach to the calling terminal (when launched from one), point Console.Out at the
+70 -6
View File
@@ -5733,6 +5733,15 @@ public sealed partial class MainForm : Form
// sees the Control packet, parses it, and fires this delegate. We marshal back
// onto the UI thread to mutate volumeBar / mute state.
receiver.OnRemoteControlReceived = HandleRemoteControlPacket;
// Relay address-proof (2026-07-27): echo the relay's cookie back verbatim so it can
// verify this address really receives — the proof that keeps us forwardable once the
// relay enforces. Echo-to-source is self-limiting (one small reply per challenge,
// never larger than what arrived), so answering unconditionally is safe.
receiver.OnAddrCheckReceived = (packet, length, remote) =>
{
try { sender.SendVia(packet, length, remote); }
catch (Exception ex) { logFile.Event($"addr-check echo to {remote} failed: {ex.GetType().Name}: {ex.Message}"); }
};
heartbeatService.Start();
}
catch (Exception ex)
@@ -8279,6 +8288,31 @@ public sealed partial class MainForm : Form
// Key + fingerprint always together, through the one shared rule (same as the service).
(currentAudioKey, currentAudioFingerprint) = RemSoundCrypto.ForPlainPassword(currentProfilePassword);
lastDerivedPassword = currentProfilePassword;
// Since 5.6 that rule also 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.
if (!string.IsNullOrEmpty(currentProfilePassword) && currentAudioKey is null && !weakPasswordExplained)
{
weakPasswordExplained = true;
logFile.Event("audio crypto: profile password fails the 5.6 strength rule — no audio until it's changed");
var advice = PasswordStrength.Critique(currentProfilePassword) ?? "";
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));
});
}
}
sender.AudioKey = currentAudioKey;
sender.AudioFingerprint = currentAudioFingerprint;
@@ -8286,20 +8320,45 @@ public sealed partial class MainForm : Form
receiver.AudioFingerprint = currentAudioFingerprint;
}
// One-shot flag for the weak-password explanation above — the dialog must not re-fire on every
// profile reapply within a session (the log line still records each derivation refusal).
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; 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>
/// 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>
private bool EnsureStreamingPassword(AccessibleCheckBox box)
{
if (!box.Checked) return true; // turning OFF never needs a password
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
var weakAdvice = PasswordStrength.Critique(currentProfilePassword ?? "");
if (!string.IsNullOrEmpty(currentProfilePassword) && weakAdvice is null) return true; // have one and it passes
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
var entered = ProfilePasswordDialog.Show(label, "", requireNonEmpty: true);
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 = "Password needs strengthening",
Heading = "Your profile password is too easy to guess",
Text = $"From this version, audio won't flow until the password is stronger. {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);
if (string.IsNullOrEmpty(entered))
{
// No password → can't stream. Put the box back without re-firing this gate.
// No acceptable password → can't stream. Put the box back without re-firing this gate.
suppressStreamingPasswordGate = true;
try { box.Checked = false; }
finally { suppressStreamingPasswordGate = false; } // a throw must not disable the gate for good
@@ -9593,7 +9652,12 @@ public sealed partial class MainForm : Form
{
if (list.Items.Count == 0)
{
var emptyText = $"No {itemKind}s available.";
// The remembered-apps list teaches its own lifecycle when empty (Ed, 2026-07-27: after
// clearing it he had no way to know how entries come back — they arrive when you TICK
// an app, so the empty state says exactly that, right where the question arises).
var emptyText = itemKind == "remembered application"
? "No remembered application. Tick an application in the list above and it will be remembered here."
: $"No {itemKind}s available.";
statusLabel.Text = emptyText;
list.AccessibleDescription = emptyText;
return;
+30 -4
View File
@@ -13,9 +13,9 @@ namespace RemSound.App;
/// </summary>
internal static class ProfilePasswordDialog
{
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false)
{
var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty);
var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty, requireStrong);
using (dialog)
{
// Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
@@ -30,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)
internal static (Form Dialog, TextBox Input) Build(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false)
{
var dialog = new Form
{
@@ -74,7 +74,8 @@ internal static class ProfilePasswordDialog
// entered nothing, pressed OK", which used to silently leave audio dead.
void TryAccept()
{
if (requireNonEmpty && textBox.Text.Trim().Length == 0)
var entered = textBox.Text.Trim();
if (requireNonEmpty && entered.Length == 0)
{
var page = new TaskDialogPage
{
@@ -91,6 +92,31 @@ internal static class ProfilePasswordDialog
textBox.SelectAll();
return;
}
// Strength gate (2026-07-27, with the derivation-cost raise). Normally NEW or CHANGED
// passwords only — re-accepting the existing password unchanged passes, so an old weak
// password never traps the user inside a casual visit to this dialog. requireStrong is
// the STREAMING gate's mode: there the whole point is that the current password failed
// the rule, so the unchanged exemption is off and a stronger one must be entered before
// audio can flow (Ed, 2026-07-27). The critique text says exactly what to do instead.
if (entered.Length > 0
&& (requireStrong || !string.Equals(entered, currentPassword.Trim(), StringComparison.Ordinal))
&& RemSound.Core.PasswordStrength.Critique(entered) 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;
}
dialog.DialogResult = DialogResult.OK;
dialog.Close();
}
@@ -78,7 +78,38 @@ internal static class ProfilePasswordManagerDialog
rows.Add((title, current, box));
}
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK };
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)
{
var entered = box.Text.Trim();
if (entered.Length > 0
&& !string.Equals(entered, original, StringComparison.Ordinal)
&& PasswordStrength.Critique(entered) 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 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.5</Version>
<Version>5.6</Version>
</PropertyGroup>
<ItemGroup>
+30 -2
View File
@@ -115,13 +115,18 @@ internal sealed class RemSoundUpdater
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
return new UpdateCheckFailed(FailureKind.HttpError, $"The latest release page is missing the expected file '{expectedAsset}'.");
}
// The detached signature over the zip (2026-07-27 release signing — see UpdateSignature).
// Recorded here, ENFORCED at install time: a release without a valid signature is refused.
var sigAsset = release.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, expectedAsset + UpdateSignature.SignatureAssetSuffix, StringComparison.OrdinalIgnoreCase));
return new UpdateAvailable(new UpdateInfo(
Tag: release.TagName,
Version: latest,
DownloadUrl: asset.BrowserDownloadUrl,
ReleaseNotes: release.Body ?? "",
ReleaseUrl: release.HtmlUrl ?? ""));
ReleaseUrl: release.HtmlUrl ?? "",
SignatureUrl: sigAsset?.BrowserDownloadUrl));
}
catch (Exception ex)
{
@@ -212,6 +217,28 @@ internal sealed class RemSoundUpdater
await src.CopyToAsync(dst, token).ConfigureAwait(false);
}
// Signature enforcement (2026-07-27): the zip must carry a valid signature by the
// embedded release key, or it is NOT installed — this is what stops a compromised
// release stream (e.g. a hijacked GitHub account) from silently shipping code to
// every user. Missing signature = refused too: every genuine release from 5.6 on is
// signed by build-release.ps1, so "no .sig asset" is itself a red flag, not a legacy
// case (older releases are BELOW this version and the updater never downgrades).
if (string.IsNullOrEmpty(info.SignatureUrl))
{
Log?.Invoke("updater: REFUSED — release has no signature file; a genuine RemSound release always ships one. Install left untouched.");
TryDeleteDirectory(stageRoot);
return false;
}
var signatureBase64 = await http.GetStringAsync(info.SignatureUrl, token).ConfigureAwait(false);
var zipBytes = await File.ReadAllBytesAsync(zipPath, token).ConfigureAwait(false);
if (!UpdateSignature.Verify(zipBytes, signatureBase64))
{
Log?.Invoke("updater: REFUSED — the release signature does not verify (tampered download or not signed by the RemSound release key). Install left untouched.");
TryDeleteDirectory(stageRoot);
return false;
}
Log?.Invoke("updater: release signature verified");
Log?.Invoke($"updater: extracting to {appDir}");
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true);
@@ -388,7 +415,8 @@ internal sealed record UpdateInfo(
Version Version,
string DownloadUrl,
string ReleaseNotes,
string ReleaseUrl);
string ReleaseUrl,
string? SignatureUrl = null);
/// <summary>Discriminated result of an update check. Replaces the v3.1.x-and-earlier
/// "UpdateInfo?" return type, which conflated "no newer version available" with "couldn't
+93 -1
View File
@@ -117,6 +117,9 @@ internal static class SelfTest
RunStep(results, "Long-run hygiene (log rotation, crash-report cap, priority-mode scope)", LongRunHygiene);
RunStep(results, "Service startup volume (boot-once decision + settings round-trip)", ServiceStartupVolume);
RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow);
RunStep(results, "Release signing (verify, tamper, key-embed match)", ReleaseSigning);
RunStep(results, "Password strength rules (gate + derivation refusal)", PasswordRules);
RunStep(results, "Relay address-proof echo (AddrCheck round-trip)", RelayAddrCheckEcho);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -1541,7 +1544,7 @@ internal static class SelfTest
};
profile.SelectedWasapiSendOutputs.Add("fake-device-id"); // a source so ApplyProfile proceeds
profile.SelectedConnectedPeers.Add("127.0.0.1:47999");
const string pw = "hunter2";
const string pw = "hunter2horse42stable"; // must pass the 5.6 strength rule or ForPlainPassword refuses it
profile.Password = RemSoundCrypto.Obfuscate(pw);
using var host = new ServiceSendHost(() => profile);
@@ -2376,6 +2379,95 @@ internal static class SelfTest
return "sealed + replay/stale/wrong-key/plaintext all rejected; skew tolerated; nonces counter-based";
}
/// <summary>Release signing (2026-07-27): the updater refuses any release zip whose detached
/// signature is missing or wrong. Mechanics proven with an ephemeral keypair (round-trip,
/// tamper, wrong key); the embedded public key must parse; and when the REAL private key is
/// present on this machine (the publisher's), a signature it produces must verify against the
/// embedded key — the mismatch that would make every updater refuse a genuine release.</summary>
private static string? ReleaseSigning()
{
var data = new byte[4096];
new Random(42).NextBytes(data);
using var ephemeral = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
var ephemeralPriv = ephemeral.ExportECPrivateKeyPem();
var ephemeralPub = ephemeral.ExportSubjectPublicKeyInfoPem();
var sig = UpdateSignature.SignWithKey(data, ephemeralPriv);
Check(UpdateSignature.VerifyWithKey(data, sig, ephemeralPub), "a genuine signature must verify");
var tampered = (byte[])data.Clone();
tampered[100] ^= 0x01;
Check(!UpdateSignature.VerifyWithKey(tampered, sig, ephemeralPub), "one flipped byte in the zip must fail verification");
Check(!UpdateSignature.Verify(data, sig), "a signature by a DIFFERENT key must fail against the embedded release key");
Check(!UpdateSignature.VerifyWithKey(data, "not-base64!!", ephemeralPub), "garbage signature text must fail, not throw");
using (var embedded = System.Security.Cryptography.ECDsa.Create())
{
embedded.ImportFromPem(UpdateSignature.PublicKeyPem); // throws (= test fails) if the constant is mangled
}
var realKeyPath = Environment.GetEnvironmentVariable("REMSOUND_SIGNING_KEY") ?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem";
if (!File.Exists(realKeyPath))
return "mechanics proven with ephemeral key; embedded key parses (publisher key not on this machine — embed-match check skipped)";
var realSig = UpdateSignature.SignWithKey(data, File.ReadAllText(realKeyPath));
Check(UpdateSignature.Verify(data, realSig),
"the on-disk private key MUST match the embedded public key — a mismatch ships a release every updater refuses");
return "round-trip + tamper + wrong-key + garbage all correct; on-disk private key matches the embedded public key";
}
/// <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");
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");
return "weak + common refused with concrete advice; derivation choke-point refuses weak on every path";
}
/// <summary>The relay address-proof (2026-07-27): an AddrCheck cookie arriving at the receiver
/// must be handed up VERBATIM for the app to echo — that echo is what proves our address to
/// the relay once it enforces. Also pins the wire type value the relay and client agreed on.</summary>
private static string? RelayAddrCheckEcho()
{
Check((byte)RemPacketType.AddrCheck == 10, "AddrCheck must stay type 10 — the relay builds this byte");
using var receiver = new RemSound.Receiver.AudioReceiver();
try { receiver.Start(FreeUdpPort()); }
catch (Exception ex) { return Skip($"could not start receiver: {ex.Message}"); }
receiver.SetOutputDevices(Array.Empty<string>());
byte[]? echoed = null;
IPEndPoint? echoedTo = null;
receiver.OnAddrCheckReceived = (packet, length, remote) => { echoed = packet.AsSpan(0, length).ToArray(); echoedTo = remote; };
// A relay-shaped challenge: v1 header, type AddrCheck, 16-byte cookie.
var cookie = new byte[16];
new Random(7).NextBytes(cookie);
var challenge = new byte[RemPacket.HeaderSize + cookie.Length];
RemPacket.WriteHeader(challenge, RemPacketType.AddrCheck, 1, 1);
cookie.CopyTo(challenge, RemPacket.HeaderSize);
var relayEp = new IPEndPoint(IPAddress.Parse("203.0.113.5"), 47830);
receiver.InjectExternalPacket(challenge, challenge.Length, relayEp);
Check(echoed is not null && echoed.AsSpan().SequenceEqual(challenge),
"the challenge must reach the app hook VERBATIM (the echo must carry the exact cookie back)");
Check(Equals(echoedTo, relayEp), "the hook must carry the source address (that's where the echo goes)");
return "type pinned at 10; challenge handed up verbatim with its source, ready to echo";
}
/// <summary>The "only install updates within this time range" gate (2026-07-26 feature):
/// same-day windows, past-midnight wraparound, boundary semantics (start in, end out),
/// the empty-selection rule, the deferred-retry arithmetic, and the list text format.</summary>
+5
View File
@@ -329,6 +329,11 @@ 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.
+48
View File
@@ -0,0 +1,48 @@
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;
if (password.Length < MinLength)
{
return $"This password is too short to protect your audio — anyone who records your stream can try millions of guesses against it. "
+ $"Use at least {MinLength} characters; longer is stronger. Three unrelated words with a number — like kettle9tiger42moon — "
+ "is easy to type and remember, and very hard to guess. Remember: every machine you connect with must be given the same new password.";
}
foreach (var common in CommonPasswords)
{
if (string.Equals(password, common, StringComparison.OrdinalIgnoreCase))
{
return "That password is one of the most commonly guessed passwords in the world, so it offers almost no protection. "
+ "Pick something personal and longer — three unrelated words with a number, like kettle9tiger42moon, works well. "
+ "Remember: every machine you connect with must be given the same new password.";
}
}
return null;
}
}
+9
View File
@@ -17,6 +17,15 @@ public enum RemPacketType : byte
/// Old peers see "unknown packet type" and silently drop, so adding this is wire-safe.
/// </summary>
Control = 5,
// 6-9 are the relay's lobby types (hello / roster / full / bye) — relay-side, not modelled here.
/// <summary>Relay address-proof challenge (2026-07-27): the relay sends a random cookie to a
/// newly seen client address and only counts that address as VERIFIED once the same packet
/// comes back from it. A forged source address can never echo, which kills the reflection
/// attack (registering a victim's spoofed address so the relay bounces audio at them). The
/// client's only job is to echo the packet verbatim to wherever it came from; pre-5.6 clients
/// drop it as an unknown type, which the relay's watch-only mode tolerates until the
/// enforcement flip.</summary>
AddrCheck = 10,
}
public enum HeartbeatKind : byte
+19 -8
View File
@@ -52,9 +52,14 @@ public static class RemSoundCrypto
private const int NonceBytes = 12; // AES-GCM standard nonce
private const int TagBytes = 16; // AES-GCM auth tag
// PBKDF2 cost. High enough to make brute-forcing a captured fingerprint expensive, low
// enough not to stall a connect on older (Win7-era) hardware. Run once per password, cached.
private const int Pbkdf2Iterations = 100_000;
// 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;
// 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
@@ -67,12 +72,18 @@ public static class RemSoundCrypto
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
/// <summary>The one rule for turning a PLAIN password into the audio credentials: null/empty →
/// (null, null) → no audio flows (encryption is mandatory); 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). The app and the service both derive through THIS.</summary>
/// (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).</summary>
public static (byte[]? Key, byte[]? Fingerprint) ForPlainPassword(string? plainPassword) =>
string.IsNullOrEmpty(plainPassword) ? (null, null) : (DeriveKey(plainPassword), Fingerprint(plainPassword));
string.IsNullOrEmpty(plainPassword) || PasswordStrength.Critique(plainPassword) is not null
? (null, null)
: (DeriveKey(plainPassword), Fingerprint(plainPassword));
/// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary>
public static byte[] DeriveKey(string? password) =>
+61
View File
@@ -0,0 +1,61 @@
using System.Security.Cryptography;
namespace RemSound.Core;
/// <summary>
/// Release-zip signature verification (2026-07-27, per the security audit: the updater previously
/// trusted whatever the GitHub release stream served — a compromised account could ship code to
/// every user silently). Every release zip is now signed at publish time (build-release.ps1) with
/// a private ECDSA P-256 key that lives ONLY on the publisher's machine; this class holds the
/// matching public key and the updater REFUSES any update whose signature is missing or does not
/// verify. Manual downloads from the release page are unaffected — this gates the automatic path.
/// The signature travels as a release asset named "&lt;zip-name&gt;.sig" (base64 of an ECDSA
/// SHA-256 signature over the raw zip bytes).
/// </summary>
public static class UpdateSignature
{
/// <summary>The RemSound release-signing PUBLIC key (SubjectPublicKeyInfo PEM). The private
/// half is NOT in the repo — it lives only with the publisher. Replacing this constant means
/// shipping a release signed by BOTH keys' owner, i.e. only the publisher can rotate it.</summary>
public const string PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENrzZmey3cvNxNyd6t55QQThTb3Zj
xR34nJr7egPq4f1Ff1IL5qA46nstniKZ3Zl6k+vcLWRr1oXzzdHvbIidcw==
-----END PUBLIC KEY-----
""";
/// <summary>Suffix of the signature asset on a GitHub release: the zip asset's name + this.</summary>
public const string SignatureAssetSuffix = ".sig";
/// <summary>True when <paramref name="signatureBase64"/> is a valid signature over
/// <paramref name="data"/> by the embedded release key. Never throws — malformed base64,
/// wrong length, wrong key all simply return false.</summary>
public static bool Verify(byte[] data, string signatureBase64) =>
VerifyWithKey(data, signatureBase64, PublicKeyPem);
/// <summary>Verification against an explicit public key — the seam the self-test uses to prove
/// the mechanics (round-trip, tamper, wrong-key) with an ephemeral throwaway keypair.</summary>
public static bool VerifyWithKey(byte[] data, string signatureBase64, string publicKeyPem)
{
try
{
var signature = Convert.FromBase64String(signatureBase64.Trim());
using var ec = ECDsa.Create();
ec.ImportFromPem(publicKeyPem);
return ec.VerifyData(data, signature, HashAlgorithmName.SHA256);
}
catch
{
return false;
}
}
/// <summary>Sign data with a private-key PEM — used by the publish pipeline (via --sign-update)
/// and the self-test's if-the-key-is-present embed-matches-key check. Returns base64.</summary>
public static string SignWithKey(byte[] data, string privateKeyPem)
{
using var ec = ECDsa.Create();
ec.ImportFromPem(privateKeyPem);
return Convert.ToBase64String(ec.SignData(data, HashAlgorithmName.SHA256));
}
}
+10
View File
@@ -961,6 +961,10 @@ public sealed class AudioReceiver : IDisposable
/// socket on either end any more.</summary>
public Action<byte[], int, IPEndPoint>? OnHeartbeatReceived { get; set; }
/// <summary>Hook for relay address-proof cookies (AddrCheck, 2026-07-27). The App echoes the
/// packet back to its source via the sender's socket — same single-port model as heartbeats.</summary>
public Action<byte[], int, IPEndPoint>? OnAddrCheckReceived { get; set; }
/// <summary>Hook for Control packets that arrive on the audio receiver's socket. Since 5.6 the
/// payload is SEALED with the profile's audio key (ControlSealing), so this hands the RAW payload
/// up — the App authenticates it (key + replay guard), validates the source against the
@@ -1000,6 +1004,12 @@ public sealed class AudioReceiver : IDisposable
// otherwise heartbeats are dropped and peer health stays "unreachable".
OnHeartbeatReceived?.Invoke(packet, length, remote);
break;
case RemPacketType.AddrCheck:
// Relay address-proof cookie (2026-07-27): hand the raw packet up so the app can
// echo it back to the relay verbatim — proving this address really receives, which
// is what unlocks relay forwarding once the relay enforces. No parsing needed here.
OnAddrCheckReceived?.Invoke(packet, length, remote);
break;
case RemPacketType.Control:
// Remote-control message (volume up/down, mute toggle). Since 5.6 the payload is
// SEALED with the profile's audio key (ControlSealing) — the receiver stays