Files
RemSound/src/RemSound.App/ProfilePasswordManagerDialog.cs
T
EdnunpandClaude Fable 5 65e999466d Test gaps 4-6: relay unit tests, updater-refusal, password walk-through
Closing the coverage gaps the review flagged as blind spots we'd be relying on at
release:

4. RELAY LOGIC TESTS. server/test_relay.py (stdlib unittest + a FakeSocket, no network)
   covers the address-proof end to end: cookie issued on join, wrong cookie rejected,
   right cookie verifies once; enforce mode WITHHOLDS forwarding from an unverified
   address then delivers after it proves itself; watch-only forwards but records
   would-block; the per-IP cap counts across BOTH v1 and v2; a NAT-rebind clears
   verification (spoof-takeover guard); a forged BYE from another address can't evict
   the victim; and bad/short/unknown-version headers are refused. Wired into
   run-tests.ps1 (Start-Process from server\, SKIPs loudly if no Python) so a relay
   change can no longer ship past the gate untested. The relay had ZERO automated
   coverage before and auto-updates every user.

5. UPDATER SIGNATURE ENFORCEMENT. Extracted the two refusal branches into a pure
   VerifyStagedRelease gate and added UpdaterRefusesUnsignedRelease: no-sig refused,
   wrong-key refused, garbage refused, tamper (good sig over changed bytes) refused,
   genuine release accepted. ReleaseSigning only proved the crypto; this proves the
   updater actually REFUSES - the hijacked-release-stream threat.

6. STREAMING PASSWORD STRENGTHENING. The accept decision is now a pure
   ProfilePasswordDialog.RejectionAdviceFor shared by BOTH password dialogs (also
   fixes the App-review trim inconsistency - manager dialog compared untrimmed). Test
   pins the load-bearing rule: requireStrong DISABLES the unchanged-exemption so an
   existing weak "Games" can't keep streaming, while casual mode still grandfathers an
   unchanged password and blocks a new weak one, trim-safe.

Plus the NVDA-hang cache assertions in PasswordRules (miss->hit, same-instance repeat,
Prewarm, empty/weak = no work).

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:11:52 +01:00

150 lines
6.4 KiB
C#

using System.Text.Json;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// The "password manager" view from Options: every saved profile listed with its password in a
/// plain, editable box. Type a new password against any profile and OK writes them all back to
/// disk. Plain (readable) boxes on purpose — a masked field reads as bullets to a screen reader,
/// which is useless; the security model already accepts the password is recoverable from the
/// profile file. Only the password field of each changed profile is rewritten; nothing else in
/// the profile is touched. Returns true if any password was changed. 2026-05-31.
/// </summary>
internal static class ProfilePasswordManagerDialog
{
public static bool Show(IWin32Window owner, ProfileStore store)
{
var (dialog, rows) = Build(store);
using (dialog)
{
if (dialog.ShowDialog(owner) != DialogResult.OK) return false;
return SaveChanges(store, rows);
}
}
/// <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, List<(string Title, string Original, TextBox Box)> Rows) Build(ProfileStore store)
{
var titles = store.ListProfileTitles();
var dialog = new Form
{
Text = "Profile passwords",
StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.Sizable,
MinimizeBox = false,
MaximizeBox = true,
ShowInTaskbar = false,
ClientSize = new Size(520, 420),
MinimumSize = new Size(420, 240),
AccessibleName = "Profile passwords",
};
var intro = new Label
{
Text = titles.Count == 0
? "You don't have any saved profiles yet. Create one with File → Save as, and it will ask you for a password."
: "Each profile has its own password. You and the person you connect to must use the same password. Edit any box and press OK to save.",
Dock = DockStyle.Top,
AutoSize = false,
Height = 48,
Padding = new Padding(12, 10, 12, 4),
};
var grid = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoScroll = true,
ColumnCount = 2,
RowCount = titles.Count,
Padding = new Padding(12, 0, 12, 8),
};
grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var rows = new List<(string Title, string Original, TextBox Box)>();
foreach (var title in titles)
{
string current;
try { current = RemSoundCrypto.Deobfuscate(store.Load(title)?.Password); }
catch { current = ""; }
var label = new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(0, 6, 10, 6) };
var box = new TextBox { Text = current, Anchor = AnchorStyles.Left | AnchorStyles.Right, AccessibleName = $"Password for profile {title}", Tag = KeyClickService.PasswordFieldTag };
grid.Controls.Add(label);
grid.Controls.Add(box);
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 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);
buttons.Controls.Add(cancelButton);
dialog.Controls.Add(grid);
dialog.Controls.Add(buttons);
dialog.Controls.Add(intro);
dialog.AcceptButton = okButton;
dialog.CancelButton = cancelButton;
return (dialog, rows);
}
private static bool SaveChanges(ProfileStore store, List<(string Title, string Original, TextBox Box)> rows)
{
var changedAny = false;
foreach (var (title, original, box) in rows)
{
var now = box.Text.Trim();
if (now == original) continue;
try
{
var path = store.PathFor(title);
if (!File.Exists(path)) continue;
var profile = JsonSerializer.Deserialize<Profile>(File.ReadAllText(path));
if (profile is null) continue;
profile.Password = RemSoundCrypto.Obfuscate(now);
File.WriteAllText(path, JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }));
changedAny = true;
}
catch
{
// Skip a profile we couldn't rewrite; the others still save.
}
}
return changedAny;
}
}