diff --git a/run-tests.ps1 b/run-tests.ps1 index 64da32e..c294033 100644 --- a/run-tests.ps1 +++ b/run-tests.ps1 @@ -93,6 +93,40 @@ if ($packet -match 'DefaultPort\s*=\s*(\d+)') { if ($Matches[1] -ne '47830') { F if ($relay -notmatch '47830') { Fail "relay no longer references port 47830"; $wireOk = $false } if ($wireOk) { Pass "relay magic / version / port still match the client header - no server change needed" } +# Relay logic unit tests (server\test_relay.py). The relay's address-proof, per-IP cap, NAT-rebind +# reset and forged-BYE rejection are pure Python guarding an internet-facing attack surface, and the +# relay auto-updates every user - a regression there would sail past the C# gate. Run them here so +# a server change can't ship un-tested. Needs a Python interpreter; if none is found we SKIP loudly +# rather than fail (the C# gate doesn't depend on Python being installed on the build box). +Write-Host "`nRelay logic tests (server\test_relay.py):" -ForegroundColor Cyan +$py = $null +foreach ($cand in @('py', 'python', 'python3')) { + $cmd = Get-Command $cand -ErrorAction SilentlyContinue + if ($cmd) { $py = $cmd.Source; break } +} +if (-not $py) { + Write-Host " [SKIP] no Python interpreter found (py/python/python3) - relay logic tests did not run" -ForegroundColor Yellow + Write-Host " WARNING: the relay's address-proof / cap / eviction logic is NOT verified on this machine." -ForegroundColor Yellow +} +else { + # Start-Process (not the call operator) so unittest's stderr can't trip $ErrorActionPreference=Stop, + # and so it runs FROM server\ where the test's relative import of remsound-relay.py resolves. + $serverDir = Join-Path $repo 'server' + $rtOut = Join-Path $env:TEMP ("rs-relay-" + [guid]::NewGuid().ToString('N') + ".txt") + $rtErr = Join-Path $env:TEMP ("rs-relay-" + [guid]::NewGuid().ToString('N') + ".err.txt") + $rp = Start-Process -FilePath $py -ArgumentList @('-m', 'unittest', 'test_relay') -WorkingDirectory $serverDir ` + -Wait -NoNewWindow -PassThru -RedirectStandardOutput $rtOut -RedirectStandardError $rtErr + $rtText = ((Get-Content -LiteralPath $rtOut -Raw -ErrorAction SilentlyContinue) + "`n" + (Get-Content -LiteralPath $rtErr -Raw -ErrorAction SilentlyContinue)) + Remove-Item $rtOut, $rtErr -Force -ErrorAction SilentlyContinue + if ($rp.ExitCode -eq 0) { + $ran = if ($rtText -match 'Ran (\d+) test') { $Matches[1] } else { '?' } + Pass "relay logic tests passed ($ran tests: addr-proof, enforce/watch, IP cap, rebind, forged-BYE, header gate)" + } + else { + Fail "relay logic tests FAILED:`n$rtText" + } +} + # ---- 4. CLI SURFACE + IN-APP SELF-TEST (these launch the app, which consolidates sounds away; # that's why the package checks ran first) ---- function Invoke-RsCli([string[]]$cliArgs) { diff --git a/server/__pycache__/remsound-relay.cpython-311.pyc b/server/__pycache__/remsound-relay.cpython-311.pyc index 293ce1a..71bb82d 100644 Binary files a/server/__pycache__/remsound-relay.cpython-311.pyc and b/server/__pycache__/remsound-relay.cpython-311.pyc differ diff --git a/server/__pycache__/test_relay.cpython-311.pyc b/server/__pycache__/test_relay.cpython-311.pyc new file mode 100644 index 0000000..e8bc9d5 Binary files /dev/null and b/server/__pycache__/test_relay.cpython-311.pyc differ diff --git a/server/test_relay.py b/server/test_relay.py new file mode 100644 index 0000000..9cbbb25 --- /dev/null +++ b/server/test_relay.py @@ -0,0 +1,176 @@ +"""Unit tests for the RemSound relay's address-proof, per-IP cap, and eviction logic. + +The relay ships to the Pi and auto-updates every user, but its branch logic (cookie verify, +watch-only vs enforce, per-IP cap across v1+v2, NAT-rebind reset, forged-BYE rejection) had no +automated coverage — a one-line regression there would sail past the C# gate and re-open the +reflection / occupation / takeover surface the 2026-07-27 address-proof was built to close. These +tests exercise that logic directly with a fake socket, no network, no real Python needed on the Pi. + +Run: py -m unittest test_relay (from the server/ folder) +""" +from __future__ import annotations + +import importlib.util +import logging +import os +import struct +import sys +import unittest +import uuid + +# The relay filename has a hyphen, so it can't be `import`ed by name — load it from its path. +# It must be registered in sys.modules BEFORE exec so @dataclass can resolve its own module. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_spec = importlib.util.spec_from_file_location("remsound_relay", os.path.join(_HERE, "remsound-relay.py")) +relay = importlib.util.module_from_spec(_spec) +sys.modules["remsound_relay"] = relay +_spec.loader.exec_module(relay) + +# A quiet logger so tests don't spam the console. +_LOG = logging.getLogger("remsound-relay-test") +_LOG.addHandler(logging.NullHandler()) +_LOG.setLevel(logging.CRITICAL) + +CID = uuid.UUID(bytes=bytes(range(16))).bytes # a fixed 16-byte client id for the v2 tests +CID2 = uuid.UUID(bytes=bytes(range(16, 32))).bytes + + +class FakeSocket: + """Records every sendto so a test can inspect what the relay emitted (cookies, forwards).""" + + def __init__(self): + self.sent: list[tuple[bytes, tuple[str, int]]] = [] + + def sendto(self, data, addr): + self.sent.append((bytes(data), addr)) + return len(data) + + +def v1_packet(pkt_type: int, payload: bytes = b"", stream_id: int = 1, seq: int = 1) -> bytes: + return relay.MAGIC + bytes([relay.V1_VERSION, pkt_type]) + struct.pack(" bytes: + return (relay.MAGIC + bytes([relay.V2_VERSION, pkt_type]) + struct.pack(" bytes | None: + """The most recent address-proof cookie the relay sent to addr (the 16 bytes after the v1 header).""" + for data, to in reversed(sock.sent): + if to == addr and len(data) >= relay.V1_HEADER_LEN + relay.ADDR_CHECK_COOKIE_LEN and data[5] == relay.TYPE_ADDR_CHECK: + return data[relay.V1_HEADER_LEN:relay.V1_HEADER_LEN + relay.ADDR_CHECK_COOKIE_LEN] + return None + + +def forwarded_to(sock: FakeSocket, addr, payload: bytes) -> bool: + """True if a packet carrying payload was forwarded to addr (ignores the cookie challenges).""" + return any(to == addr and payload in data and data[5] != relay.TYPE_ADDR_CHECK for data, to in sock.sent) + + +class AddrCheckV1(unittest.TestCase): + """The shipping RemSound client is v1-framed (pairwise); these are the load-bearing cases.""" + + def test_cookie_issued_on_join_and_verifies_on_echo(self): + r = make_relay() + a, b = ("10.0.0.1", 5001), ("10.0.0.2", 5002) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"aud-a"), a) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"aud-b"), b) + cookie_a = cookie_sent_to(r.sock, a) + self.assertIsNotNone(cookie_a, "the relay must challenge a newly seen address with a cookie") + # Wrong cookie must NOT verify. + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, b"\x00" * 16), a) + self.assertEqual(r.stats.addr_checks_verified, 0, "a wrong cookie must not verify an address") + # The genuine cookie, echoed back, verifies exactly once (idempotent thereafter). + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie_a), a) + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie_a), a) + self.assertEqual(r.stats.addr_checks_verified, 1, "echoing the right cookie verifies once, not repeatedly") + + def test_enforce_blocks_unverified_then_forwards_after_verify(self): + r = make_relay(require_addr_check=True) + a, b = ("10.0.0.1", 5001), ("10.0.0.2", 5002) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-a"), a) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-b"), b) + cookie_a = cookie_sent_to(r.sock, a) # captured before we clear the socket + self.assertIsNotNone(cookie_a, "A must have been challenged with a cookie on join") + r.sock.sent.clear() + # B streams while A is unverified → enforcement withholds it. + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"SECRET-AUDIO"), b) + self.assertFalse(forwarded_to(r.sock, a, b"SECRET-AUDIO"), "unverified A must NOT receive forwarded audio under enforcement") + self.assertGreater(r.stats.blocked_unverified, 0, "the withheld forward must be counted") + # A proves its address, then the same stream reaches it. + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie_a), a) + r.sock.sent.clear() + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"NOW-DELIVERED"), b) + self.assertTrue(forwarded_to(r.sock, a, b"NOW-DELIVERED"), "a verified address must receive forwarded audio") + + def test_watch_only_forwards_but_records_would_block(self): + r = make_relay(require_addr_check=False) + a, b = ("10.0.0.1", 5001), ("10.0.0.2", 5002) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-a"), a) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-b"), b) + r.sock.sent.clear() + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"WATCHED"), b) + self.assertTrue(forwarded_to(r.sock, a, b"WATCHED"), "watch-only mode must still forward (never break pre-5.6 clients)") + self.assertGreater(r.stats.would_block_unverified, 0, "watch-only must record who WOULD have been blocked") + self.assertEqual(r.stats.blocked_unverified, 0, "watch-only must not actually block") + + +class AddrCheckV2(unittest.TestCase): + def test_rebind_resets_verification(self): + r = make_relay() + addr1, addr2 = ("10.0.0.9", 6001), ("10.0.0.9", 6002) + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID, b"a"), addr1) + cookie = cookie_sent_to(r.sock, addr1) + self.assertIsNotNone(cookie) + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie), addr1) # echo comes back v1-framed + self.assertTrue(r.v2_clients[uuid.UUID(bytes=CID)].verified, "a correct echo must verify the v2 client") + # The same client_id appearing from a NEW address must drop verification (spoof-takeover guard). + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID, b"a"), addr2) + self.assertFalse(r.v2_clients[uuid.UUID(bytes=CID)].verified, "an endpoint rebind must clear verified") + + def test_forged_bye_from_other_address_rejected(self): + r = make_relay() + addr_a, addr_b = ("10.0.0.1", 7001), ("10.0.0.2", 7002) + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID, b"a"), addr_a) + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID2, b"b"), addr_b) + # B forges a BYE for A's client_id from B's own address — must be refused; A stays. + r.handle_packet(v2_packet(relay.TYPE_LOBBY_BYE, CID), addr_b) + self.assertIn(uuid.UUID(bytes=CID), r.v2_clients, "a BYE from a non-registered address must not evict the victim") + + +class Caps(unittest.TestCase): + def test_ip_cap_counts_across_protocols(self): + r = make_relay(max_clients=10) + # Four v2 clients from one IP fill that IP's quota (MAX_ENTRIES_PER_IP == 4). + ip = "9.9.9.9" + for i in range(4): + cid = uuid.UUID(bytes=bytes([i]) + bytes(15)).bytes + r.handle_packet(v2_packet(relay.TYPE_AUDIO, cid, b"x"), (ip, 8000 + i)) + self.assertEqual(len(r.v2_clients), 4) + # A v1 peer from the SAME IP must be refused — the cap counts both protocols. + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"x"), (ip, 8100)) + self.assertGreater(r.stats.rejected_ip_cap, 0, "a 5th entry from a capped IP must be refused") + self.assertEqual(len(r.v1_peers), 0, "the over-cap v1 peer must not be admitted") + # A different IP is unaffected. + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"x"), ("8.8.8.8", 8100)) + self.assertEqual(len(r.v1_peers), 1, "a peer from a different IP must still be admitted") + + +class HeaderGate(unittest.TestCase): + def test_bad_headers_rejected(self): + r = make_relay() + r.handle_packet(b"XY", ("1.1.1.1", 1)) # too short + r.handle_packet(b"BADX\x01\x02" + bytes(6), ("1.1.1.1", 1)) # wrong magic + r.handle_packet(relay.MAGIC + bytes([99, 2]) + bytes(6), ("1.1.1.1", 1)) # unknown version + self.assertEqual(r.stats.rejected_bad_header, 3, "short / wrong-magic / unknown-version must all be rejected") + self.assertEqual(len(r.v1_peers), 0) + self.assertEqual(len(r.v2_clients), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/RemSound.App/ProfilePasswordDialog.cs b/src/RemSound.App/ProfilePasswordDialog.cs index 3fb9d9d..8c84db6 100644 --- a/src/RemSound.App/ProfilePasswordDialog.cs +++ b/src/RemSound.App/ProfilePasswordDialog.cs @@ -13,6 +13,21 @@ namespace RemSound.App; /// internal static class ProfilePasswordDialog { + /// 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 — 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). + internal static string? RejectionAdviceFor(string entered, string current, bool requireStrong) + { + 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); @@ -92,15 +107,9 @@ 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) + // 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 { diff --git a/src/RemSound.App/ProfilePasswordManagerDialog.cs b/src/RemSound.App/ProfilePasswordManagerDialog.cs index 91e0a68..c15624c 100644 --- a/src/RemSound.App/ProfilePasswordManagerDialog.cs +++ b/src/RemSound.App/ProfilePasswordManagerDialog.cs @@ -86,10 +86,9 @@ internal static class ProfilePasswordManagerDialog { 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) + // 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 { diff --git a/src/RemSound.App/RemSoundUpdater.cs b/src/RemSound.App/RemSoundUpdater.cs index e70a237..fb0d724 100644 --- a/src/RemSound.App/RemSoundUpdater.cs +++ b/src/RemSound.App/RemSoundUpdater.cs @@ -217,27 +217,19 @@ 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); + // Signature enforcement (2026-07-27) — the release must carry a valid signature by the + // embedded key or it is NOT installed. The decision is a pure gate (VerifyStagedRelease) + // so a test can pin the control flow — the thing ReleaseSigning's crypto test can't see — + // independently of the HTTP/Process machinery around it. + var signatureBase64 = string.IsNullOrEmpty(info.SignatureUrl) + ? null + : await http.GetStringAsync(info.SignatureUrl, token).ConfigureAwait(false); var zipBytes = await File.ReadAllBytesAsync(zipPath, token).ConfigureAwait(false); - if (!UpdateSignature.Verify(zipBytes, signatureBase64)) + if (!VerifyStagedRelease(zipBytes, info.SignatureUrl, signatureBase64, Log)) { - 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); + TryDeleteDirectory(stageRoot); // refused → leave the install untouched return false; } - Log?.Invoke("updater: release signature verified"); Log?.Invoke($"updater: extracting to {appDir}"); System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true); @@ -291,6 +283,30 @@ internal sealed class RemSoundUpdater } } + /// The pure signature gate: may this downloaded release be installed? False (refuse, + /// install left untouched) when there is no signature asset, or the signature doesn't verify + /// against the embedded release key — the two branches that stop a hijacked release stream from + /// shipping code to every user. Extracted from so the + /// control flow is unit-testable apart from the HTTP/extract/Process machinery (the crypto alone + /// is covered elsewhere; this pins that the updater actually REFUSES). A missing signature is + /// refused, not tolerated: every genuine release from 5.6 on is signed, and the updater never + /// downgrades, so "no .sig" is a red flag, not a legacy case. + internal static bool VerifyStagedRelease(byte[] zipBytes, string? signatureUrl, string? signatureBase64, Action? log) + { + if (string.IsNullOrEmpty(signatureUrl) || string.IsNullOrEmpty(signatureBase64)) + { + log?.Invoke("updater: REFUSED — release has no signature file; a genuine RemSound release always ships one. Install left untouched."); + return 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."); + return false; + } + log?.Invoke("updater: release signature verified"); + return true; + } + /// If the zip extracted to a single subfolder (typical when GitHub zips a tag), /// return that subfolder so the copy works from the inner level. Otherwise return the /// staging dir itself. diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index b2b49f3..b1321a5 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -129,6 +129,8 @@ internal static class SelfTest 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, "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, "Relay address-proof echo (AddrCheck round-trip)", RelayAddrCheckEcho); @@ -2455,6 +2457,76 @@ internal static class SelfTest return "round-trip + tamper + wrong-key + garbage all correct; on-disk private key matches the embedded public key"; } + /// The updater's signature ENFORCEMENT control flow (2026-07-27) — the piece + /// ReleaseSigning's crypto test can't see: that the updater actually REFUSES a release with no + /// signature and one whose signature doesn't verify, and only proceeds on a genuine one. Guards + /// the hijacked-release-stream threat the signing was built for. + private static string? UpdaterRefusesUnsignedRelease() + { + var zip = new byte[8192]; + new Random(99).NextBytes(zip); + + // No signature asset at all → refused (a genuine 5.6+ release always ships one). + Check(!RemSoundUpdater.VerifyStagedRelease(zip, signatureUrl: null, signatureBase64: null, log: null), + "a release with NO signature asset must be refused"); + Check(!RemSoundUpdater.VerifyStagedRelease(zip, signatureUrl: "https://x/RemSound-v9.9.zip.sig", signatureBase64: null, log: null), + "a signature URL that fetched nothing must be refused"); + + // A signature by a DIFFERENT key (an attacker's, or a tampered download) → refused. + using (var attacker = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256)) + { + var forged = UpdateSignature.SignWithKey(zip, attacker.ExportECPrivateKeyPem()); + Check(!RemSoundUpdater.VerifyStagedRelease(zip, "https://x/z.sig", forged, null), + "a release signed by a NON-release key must be refused (the hijack case)"); + } + Check(!RemSoundUpdater.VerifyStagedRelease(zip, "https://x/z.sig", "not-valid-base64", null), + "a garbage signature must be refused, not throw"); + + // A genuine signature by the embedded release key → accepted. Only producible with the + // on-disk private key (Ed's box / this session); elsewhere the accept branch is noted skipped. + var realKeyPath = Environment.GetEnvironmentVariable("REMSOUND_SIGNING_KEY") ?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem"; + if (!File.Exists(realKeyPath)) + return "no-sig + wrong-key + garbage all refused (genuine-accept branch needs the publisher key — noted)"; + var good = UpdateSignature.SignWithKey(zip, File.ReadAllText(realKeyPath)); + Check(RemSoundUpdater.VerifyStagedRelease(zip, "https://x/RemSound-v9.9.zip.sig", good, null), + "a release genuinely signed by the release key must be accepted"); + // And the SAME good signature over TAMPERED bytes must be refused (integrity, end to end). + var tamperedZip = (byte[])zip.Clone(); + tamperedZip[0] ^= 0xFF; + Check(!RemSoundUpdater.VerifyStagedRelease(tamperedZip, "https://x/z.sig", good, null), + "a valid signature over DIFFERENT bytes must be refused (download tamper)"); + return "no-sig + wrong-key + garbage + tamper all refused; a genuine release accepted"; + } + + /// 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. + private static string? StreamingPasswordStrengthening() + { + // 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"; + } + /// 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.