Release v3.3: end-to-end encrypted audio, plus cue and reliability fixes

Headline: all audio is now encrypted (AES-256-GCM), keyed by a per-profile
password. Mandatory — v3.3 only interoperates with v3.3+.

Encryption
- RemSoundCrypto (Core): PBKDF2 key derivation, AES-GCM encrypt/decrypt
  (low-alloc, into-span), password fingerprint, light on-disk obfuscation.
- Wire: SenderLane encrypts the audio payload (PCM split across parts when the
  +28 overhead crosses MTU); AudioReceiver/StreamSession decrypt via a shared
  single-thread AudioDecryptor. Fingerprint piggybacks on the Format packet
  (offset 36, backward-compatible) so a peer can detect a password mismatch.
- Profile.Password (scrambled), carried through BuildCurrentProfile; MainForm
  derives + pushes the key/fingerprint to sender + receiver (RecomputeAudioCrypto).
- UX: ask-for-password on profile create; File -> Change this profile's password
  (ProfilePasswordDialog); Options -> Profile passwords (manager); a gate that
  prompts before streaming without a password; and a clear "passwords don't
  match" / "peer needs to update" message driven by the fingerprint.

Cue fixes
- CuePlayer (NAudio) replaces System.Media.SoundPlayer, which silently failed
  on the 96 kHz/24-bit cue WAVs (and any custom file) — cues now play reliably,
  resampled to 48 kHz/16-bit. Also fixes the Preferences preview button.
- Connect/disconnect cues now audio-gated with hysteresis: connected when audio
  flows OR heartbeat healthy; lost only when audio stops AND heartbeat
  unreachable. Kills false disconnects and the receive-only "no cues" case.
- Honest cue logging (played / muted / not loaded).

Smaller
- Endpoint stickiness: keep the audio target pinned to the heartbeat-proven
  address instead of chasing a multi-homed peer's other (unreachable) address.
- "Online/offline" label now audio+heartbeat aware, not discovery-only.
- "Show what's new after each update" preference (on by default).

Docs: About v3.3 block, RELEASE_NOTES, README (encryption as a headline),
manual section 12 "Passwords and encryption" (+ renumber), MANUAL.md regenerated.
Version 3.2.0 -> 3.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-02 23:57:32 +01:00
co-authored by Claude Opus 4.8
parent cdcac859c4
commit 959720f54d
20 changed files with 1330 additions and 115 deletions
@@ -0,0 +1,105 @@
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 titles = store.ListProfileTitles();
using 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}" };
grid.Controls.Add(label);
grid.Controls.Add(box);
rows.Add((title, current, box));
}
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);
buttons.Controls.Add(cancelButton);
dialog.Controls.Add(grid);
dialog.Controls.Add(buttons);
dialog.Controls.Add(intro);
dialog.AcceptButton = okButton;
dialog.CancelButton = cancelButton;
if (dialog.ShowDialog(owner) != DialogResult.OK) return false;
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;
}
}