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:
co-authored by
Claude Opus 4.8
parent
cdcac859c4
commit
959720f54d
@@ -20,6 +20,47 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.3
|
||||
|
||||
Your audio is now encrypted, end to end, so you no
|
||||
longer need a VPN just to keep it private.
|
||||
|
||||
How it works: every profile has a password. You and
|
||||
the person you're connecting to must use the SAME
|
||||
password — then your audio is scrambled on the way
|
||||
out and only unscrambled at the other end. Anyone in
|
||||
between hears nothing usable. The password is what
|
||||
lets you connect: matching passwords connect, and
|
||||
you hear each other; different passwords mean no
|
||||
audio (RemSound tells you when that happens).
|
||||
|
||||
Setting a password: when you create a profile it asks
|
||||
for one. You can change it any time with File → Change
|
||||
this profile's password, and you can see and edit the
|
||||
passwords for ALL your profiles in one place under
|
||||
Options → Profile passwords. If you try to start
|
||||
sending or receiving on a profile that has no password
|
||||
yet, RemSound asks you to set one first.
|
||||
|
||||
Adds almost no delay — the scrambling takes millionths
|
||||
of a second per packet, far less than the audio itself.
|
||||
|
||||
Important: because the audio format changed, v3.3 can
|
||||
only talk to other v3.3 (and later) copies. Anyone you
|
||||
connect with needs to update to v3.3 too.
|
||||
|
||||
Also in this release: connect, disconnect and the
|
||||
other cue sounds now play reliably whatever format the
|
||||
WAV is in (they used to be hit-and-miss with high-
|
||||
resolution files); the connect/disconnect cues now
|
||||
follow the actual audio, so you won't hear a false
|
||||
"disconnect" while sound is still playing; the system
|
||||
tray icon sticks to a peer's working address instead
|
||||
of hopping between a VPN and a LAN address (which could
|
||||
cause crackle on some setups); and RemSound now offers
|
||||
to show you what's new after each update (on by
|
||||
default; turn it off in Preferences).
|
||||
|
||||
RemSound v3.2
|
||||
|
||||
A new audio cue, plus the reliability work from
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.SampleProviders;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Plays a short cue WAV reliably, regardless of its format. Replaces System.Media.SoundPlayer,
|
||||
/// which only handles bog-standard 16-bit / 44.1–48 kHz PCM and silently fails (plays nothing,
|
||||
/// intermittently) on anything else — which is exactly why RemSound's own 96 kHz / 24-bit cue
|
||||
/// files, and any custom WAV a user browses for, played unpredictably (2026-06-02 investigation
|
||||
/// of Ed's "didn't hear the disconnect cue" report).
|
||||
///
|
||||
/// Each Play() reads the file with NAudio (which copes with 16/24/32-bit and any sample rate),
|
||||
/// resamples to a universally-safe 48 kHz / 16-bit with a pure-managed resampler (no Media
|
||||
/// Foundation, so it works on Windows 7 too), and renders it through WaveOut to the default
|
||||
/// output device. Setup happens on a background thread so a cue never hitches the UI, and the
|
||||
/// output + reader are disposed when playback finishes. Cues are occasional and short, so a
|
||||
/// fresh device-open per play is fine and keeps the class stateless and overlap-safe (two cues
|
||||
/// close together simply mix). 2026-06-02.
|
||||
/// </summary>
|
||||
internal sealed class CuePlayer : IDisposable
|
||||
{
|
||||
private readonly string filePath;
|
||||
|
||||
public CuePlayer(string filePath) => this.filePath = filePath;
|
||||
|
||||
public void Play()
|
||||
{
|
||||
var path = filePath;
|
||||
Task.Run(() =>
|
||||
{
|
||||
AudioFileReader? reader = null;
|
||||
WaveOutEvent? output = null;
|
||||
try
|
||||
{
|
||||
reader = new AudioFileReader(path);
|
||||
ISampleProvider source = reader;
|
||||
if (reader.WaveFormat.SampleRate != 48000)
|
||||
{
|
||||
source = new WdlResamplingSampleProvider(source, 48000);
|
||||
}
|
||||
output = new WaveOutEvent();
|
||||
var capturedReader = reader;
|
||||
var capturedOutput = output;
|
||||
output.PlaybackStopped += (_, _) =>
|
||||
{
|
||||
try { capturedOutput.Dispose(); } catch { /* best-effort */ }
|
||||
try { capturedReader.Dispose(); } catch { /* best-effort */ }
|
||||
};
|
||||
output.Init(new SampleToWaveProvider16(source));
|
||||
output.Play();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A cue that won't load or play must never disturb anything. Clean up and move on.
|
||||
try { output?.Dispose(); } catch { /* ignore */ }
|
||||
try { reader?.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing persistent to release — each Play owns and disposes its own reader + output.
|
||||
}
|
||||
}
|
||||
+391
-37
@@ -307,26 +307,29 @@ public sealed class MainForm : Form
|
||||
// Last time TryAdoptLiveHeartbeatAddress re-pointed the sender at a peer's live address.
|
||||
// Gives a fresh endpoint time to prove healthy before another swap can fire (anti-thrash).
|
||||
private DateTime lastAddressAdoptionUtc = DateTime.MinValue;
|
||||
// Tracks the most recent PeerHealthState we observed for each peer endpoint, so we can
|
||||
// detect transitions and play the appropriate cue. Connect: any state → Healthy.
|
||||
// Disconnect: any state → Unreachable. Stale doesn't fire (it's a transient).
|
||||
private readonly Dictionary<string, PeerHealthState> previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private System.Media.SoundPlayer? connectSound;
|
||||
private System.Media.SoundPlayer? disconnectSound;
|
||||
// Tracks whether each peer was last considered CONNECTED, for the connect/disconnect cues.
|
||||
// "Connected" now means audio is actually flowing OR the heartbeat is healthy — not the
|
||||
// heartbeat alone (see DetectAndAnnouncePeerHealthTransitions). The bool, rather than the
|
||||
// raw health state, gives natural hysteresis: once connected we stay connected until audio
|
||||
// genuinely stops AND the heartbeat goes unreachable, so a heartbeat blip while audio keeps
|
||||
// playing never fires a false disconnect cue. 2026-05-31 rewrite.
|
||||
private readonly Dictionary<string, bool> peerConnectedState = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CuePlayer? connectSound;
|
||||
private CuePlayer? disconnectSound;
|
||||
// Recording start/stop cues. Played via SoundPlayer to the default Windows output —
|
||||
// same path as connect/disconnect. They don't pass through our recording taps (those
|
||||
// sit on the internal sender mix bus and receiver render path), so they don't appear
|
||||
// in normal recordings. A user who has a WASAPI loopback of the same output device as
|
||||
// a capture source would still get them, but that's their loopback configuration, not
|
||||
// anything the recorder is doing.
|
||||
private System.Media.SoundPlayer? recordStartSound;
|
||||
private System.Media.SoundPlayer? recordStopSound;
|
||||
private CuePlayer? recordStartSound;
|
||||
private CuePlayer? recordStopSound;
|
||||
// Profile-save and profile-switch cues, added 2026-05-28 alongside the move of all
|
||||
// default WAVs into a sounds\ subfolder. Save fires after a successful File → Save /
|
||||
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
||||
private System.Media.SoundPlayer? saveSound;
|
||||
private System.Media.SoundPlayer? profileSwitchSound;
|
||||
private System.Media.SoundPlayer? updateSound;
|
||||
private CuePlayer? saveSound;
|
||||
private CuePlayer? profileSwitchSound;
|
||||
private CuePlayer? updateSound;
|
||||
// Labels for the three send/receive device lists, captured at layout time so they can be
|
||||
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
|
||||
// (driver channel pairs). null until BuildLayout has run.
|
||||
@@ -458,6 +461,24 @@ public sealed class MainForm : Form
|
||||
// it shouldn't block shutdown when his screen reader can't reach the dirty-prompt.
|
||||
// Toggled via File → Lock profile (read-only) and persisted on the profile JSON.
|
||||
private bool currentProfileReadOnly;
|
||||
// The active profile's encryption password, in PLAIN text (the profile JSON stores it
|
||||
// lightly scrambled — see RemSoundCrypto.Obfuscate). "" = no password set. Two peers can
|
||||
// exchange audio only when their profile passwords match. Set from the loaded profile in
|
||||
// the constructor, changed via File → Change this profile's password, and carried back into
|
||||
// every save by BuildCurrentProfile. 2026-05-31 (always-on encryption, in development).
|
||||
private string currentProfilePassword = "";
|
||||
// The AES key + fingerprint derived from currentProfilePassword, cached so the slow key
|
||||
// derivation only runs when the password actually changes. Pushed down to the sender and
|
||||
// receiver by RecomputeAudioCrypto. Null when no password is set (then no audio flows).
|
||||
private byte[]? currentAudioKey;
|
||||
private byte[]? currentAudioFingerprint;
|
||||
private string? lastDerivedPassword;
|
||||
// Re-entrancy guard for the "you need a password to stream" gate, so programmatically
|
||||
// un-ticking the send/receive box (when the user cancels the password prompt) doesn't
|
||||
// re-fire the gate. And a record of which peers we've already warned about a password
|
||||
// mismatch / out-of-date version, so the warning shows once per change, not every second.
|
||||
private bool suppressStreamingPasswordGate;
|
||||
private readonly Dictionary<System.Net.IPAddress, PeerSecurityStatus> lastSecurityWarned = new();
|
||||
// The actual menu item — kept as a field so profile-load (or read-only toggle) can
|
||||
// sync .Checked without rebuilding the menu. CheckOnClick lets the menu item flip
|
||||
// itself on every click; the CheckedChanged handler reads the new value and runs
|
||||
@@ -568,6 +589,8 @@ public sealed class MainForm : Form
|
||||
// template (profile == null) implicitly starts as not-read-only; users still have
|
||||
// the menu toggle available if they want to lock the working state mid-session.
|
||||
currentProfileReadOnly = profile?.ReadOnly ?? false;
|
||||
// Unscramble the profile's stored password into the in-memory plain-text working value.
|
||||
currentProfilePassword = RemSoundCrypto.Deobfuscate(profile?.Password);
|
||||
// Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into
|
||||
// the in-memory settings cache BEFORE the rest of the constructor body reads from it.
|
||||
// Control states (device ticks, checkboxes, volume) come later in OnShown.
|
||||
@@ -902,8 +925,8 @@ public sealed class MainForm : Form
|
||||
ApplyAsioMode();
|
||||
|
||||
// --- Wire main-form events ---
|
||||
receiveAudioCheckbox.CheckedChanged += (_, _) => { HandleCapabilityChange(); MarkProfileDirty(); };
|
||||
sendMyAudioCheckbox.CheckedChanged += (_, _) => { HandleCapabilityChange(); MarkProfileDirty(); };
|
||||
receiveAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(receiveAudioCheckbox);
|
||||
sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox);
|
||||
volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); };
|
||||
WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device");
|
||||
receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
|
||||
@@ -1164,12 +1187,61 @@ public sealed class MainForm : Form
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If the user opted in, show the About box once on the first launch after an update
|
||||
// installed, so they see what's new. BeginInvoke so it opens after Shown completes.
|
||||
BeginInvoke(new Action(MaybeShowWhatsNewAfterUpdate));
|
||||
};
|
||||
|
||||
statusTimer.Start();
|
||||
deviceRefreshTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
|
||||
/// running version changed since the last launch we recorded, open the About box once so
|
||||
/// they see what changed in the update just installed. Always records the current version
|
||||
/// so the change is detected exactly once. A fresh install (no version recorded yet) does
|
||||
/// NOT count as an update. 2026-05-31.</summary>
|
||||
private void MaybeShowWhatsNewAfterUpdate()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "";
|
||||
|
||||
AppConfig cfg;
|
||||
try { cfg = AppConfig.Load(); }
|
||||
catch { return; }
|
||||
|
||||
var versionChanged = !string.IsNullOrEmpty(cfg.LastWhatsNewVersion)
|
||||
&& cfg.LastWhatsNewVersion != current;
|
||||
|
||||
if (cfg.ShowWhatsNewAfterUpdate && versionChanged)
|
||||
{
|
||||
try
|
||||
{
|
||||
logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}");
|
||||
using var dlg = new AboutDialog();
|
||||
dlg.ShowDialog(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logFile.Event($"what's new: failed to open About: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Record the current version so the next change is detected once. Reload first so we
|
||||
// don't clobber a concurrent config write (e.g. the startup update-check timestamp).
|
||||
if (cfg.LastWhatsNewVersion != current)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fresh = AppConfig.Load();
|
||||
fresh.LastWhatsNewVersion = current;
|
||||
fresh.Save();
|
||||
}
|
||||
catch { /* harmless — at worst we re-show next launch */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== UI layout =====================
|
||||
|
||||
private void BuildLayout()
|
||||
@@ -1307,6 +1379,15 @@ public sealed class MainForm : Form
|
||||
OnLockProfileToggled(lockProfileMenuItem.Checked);
|
||||
};
|
||||
|
||||
// Change this profile's encryption password. Alt+F, P — 'p' is free in the File menu
|
||||
// (O / R / S / A / M / L / N / X are taken). Opens a small dialog showing the current
|
||||
// password (in plain text, so a screen reader can read it) with OK / Cancel.
|
||||
var changePasswordItem = new ToolStripMenuItem("Change this profile's &password...")
|
||||
{
|
||||
AccessibleName = "Change this profile's password",
|
||||
};
|
||||
changePasswordItem.Click += (_, _) => ChangeProfilePassword();
|
||||
|
||||
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
|
||||
{
|
||||
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
|
||||
@@ -1333,6 +1414,7 @@ public sealed class MainForm : Form
|
||||
saveAsItem,
|
||||
renameItem,
|
||||
lockProfileMenuItem,
|
||||
changePasswordItem,
|
||||
new ToolStripSeparator(),
|
||||
minimiseItem,
|
||||
new ToolStripSeparator(),
|
||||
@@ -1383,11 +1465,20 @@ public sealed class MainForm : Form
|
||||
};
|
||||
prefsItem.Click += (_, _) => OpenPreferencesDialog();
|
||||
|
||||
// Password manager — list every profile with its password, edit any of them in one place.
|
||||
// 'w' mnemonic (pass&words) is free in the Options menu (s / K / t / P are taken).
|
||||
var profilePasswordsItem = new ToolStripMenuItem("Profile pass&words...")
|
||||
{
|
||||
AccessibleName = "Profile passwords",
|
||||
};
|
||||
profilePasswordsItem.Click += (_, _) => OpenProfilePasswordManager();
|
||||
|
||||
optionsMenu.DropDownItems.AddRange(new ToolStripItem[]
|
||||
{
|
||||
recordingSettingsItem,
|
||||
keyboardItem,
|
||||
startupBehaviourItem,
|
||||
profilePasswordsItem,
|
||||
new ToolStripSeparator(),
|
||||
prefsItem,
|
||||
});
|
||||
@@ -2834,7 +2925,15 @@ public sealed class MainForm : Form
|
||||
else
|
||||
{
|
||||
var label = selectedPeerLabels.GetValueOrDefault(id, ep.Address.ToString());
|
||||
var ghost = new PeerAnnouncement(id, $"{label} (offline)", ep.Port, true, true, DateTime.UtcNow, ep.Address);
|
||||
// Don't call a peer "offline" just because DISCOVERY briefly lost sight of it —
|
||||
// if audio is still arriving from it, or its heartbeat is healthy, it's plainly
|
||||
// still connected. Discovery beacons are easy to miss for a second; the audio
|
||||
// stream and heartbeat are the real signal. Only tag "(offline)" when it's gone
|
||||
// by every measure. 2026-06-02 (Ed's "offline but still sending audio" report).
|
||||
var stillThere = receiver.IsAudioFlowingFrom(ep.Address, TimeSpan.FromSeconds(3))
|
||||
|| IsEndpointHeartbeatHealthy(ep);
|
||||
var suffix = stillThere ? "" : " (offline)";
|
||||
var ghost = new PeerAnnouncement(id, $"{label}{suffix}", ep.Port, true, true, DateTime.UtcNow, ep.Address);
|
||||
desired.Add((new PeerListItem(ghost), id));
|
||||
}
|
||||
}
|
||||
@@ -3158,6 +3257,10 @@ public sealed class MainForm : Form
|
||||
// the audio NAT pinhole on the audio port — no separate socket, no +2 port.
|
||||
heartbeatService?.SetTrackedPeers(endpoints);
|
||||
|
||||
// Push the current profile-password key + fingerprint down to the sender and receiver so
|
||||
// audio is encrypted/decrypted with it. Cheap when the password hasn't changed.
|
||||
RecomputeAudioCrypto();
|
||||
|
||||
// Sender does NOT depend on a peer being currently online. As long as the user has ticked
|
||||
// "Send my audio" AND a capture device, we keep capturing and emitting UDP. If no peer is
|
||||
// selected, packets just go nowhere; the moment a peer is ticked, packets start flowing.
|
||||
@@ -3898,15 +4001,27 @@ public sealed class MainForm : Form
|
||||
foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer;
|
||||
|
||||
// If a selected peer's announced address changed (DHCP renewal, network switch),
|
||||
// update the cached endpoint so the sender follows the new IP.
|
||||
// update the cached endpoint so the sender follows the new IP — BUT only when the
|
||||
// endpoint we're currently using has actually stopped working.
|
||||
//
|
||||
// Why the guard: a peer reachable at two addresses at once — e.g. a VPN address AND a
|
||||
// LAN address — announces itself from both, and discovery reports whichever it heard
|
||||
// last. Blindly following that made the tracked endpoint ping-pong between the two
|
||||
// every couple of seconds. Because this one endpoint feeds the audio sender, the
|
||||
// heartbeat, AND the receiver's allow-list, the ping-pong meant a chunk of audio was
|
||||
// aimed at — or accepted only from — an address that doesn't actually reach the peer,
|
||||
// heard as heavy crackle (Tech Singer's Win7-over-VPN report, 2026-05-31). Keeping the
|
||||
// endpoint pinned while it's still passing heartbeats stops the thrash. A genuine move
|
||||
// (DHCP renewal, Wi-Fi switch) makes the old endpoint go unreachable first, at which
|
||||
// point the guard lets the move through; TryAdoptLiveHeartbeatAddress backs it up.
|
||||
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
|
||||
{
|
||||
if (!knownPeers.TryGetValue(id, out var peer)) continue;
|
||||
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
|
||||
if (!newEndpoint.Equals(oldEndpoint))
|
||||
if (!newEndpoint.Equals(oldEndpoint) && !IsEndpointHeartbeatHealthy(oldEndpoint))
|
||||
{
|
||||
selectedPeerEndpoints[id] = newEndpoint;
|
||||
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint}");
|
||||
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint not healthy)");
|
||||
}
|
||||
selectedPeerLabels[id] = peer.Name;
|
||||
}
|
||||
@@ -3957,6 +4072,23 @@ public sealed class MainForm : Form
|
||||
receiver.SetAllowedSenders(SelectedSendEndpoints());
|
||||
}
|
||||
|
||||
/// <summary>True if the heartbeat currently considers <paramref name="endpoint"/> healthy —
|
||||
/// i.e. we're getting pongs back from exactly that address+port right now. Used to keep the
|
||||
/// audio target pinned to a proven-good endpoint instead of chasing a multi-homed peer's
|
||||
/// other (possibly unreachable) advertised address every discovery refresh. 2026-05-31.</summary>
|
||||
private bool IsEndpointHeartbeatHealthy(IPEndPoint endpoint)
|
||||
{
|
||||
if (heartbeatService is null) return false;
|
||||
foreach (var h in heartbeatService.GetAllPeerHealth())
|
||||
{
|
||||
if (h.State == PeerHealthState.Healthy && h.AudioEndpoint.Equals(endpoint))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stale-address recovery. When exactly one tracked peer has gone Unreachable (its
|
||||
/// resolved address — often a stale DNS answer — has no host behind it) and exactly one
|
||||
@@ -4335,6 +4467,9 @@ public sealed class MainForm : Form
|
||||
// send / receive routing (WASAPI / ASIO / both). 1 Hz cadence is fine — the user is
|
||||
// hovering, not staring at a counter — and BuildTrayTooltip is allocation-cheap.
|
||||
trayController.SetTooltip(BuildTrayTooltip());
|
||||
// Surface any password mismatch / out-of-date peer the receiver has spotted (once per
|
||||
// change). Cheap when everything matches.
|
||||
CheckPeerSecurity();
|
||||
// Periodic native-memory reaper. SustainedLowLatency GC mode (set in Program.Main)
|
||||
// explicitly avoids gen2 collections to keep audio scheduling smooth — but that same
|
||||
// suppression means finalizers for IDisposable wrappers that didn't get explicit
|
||||
@@ -5013,6 +5148,20 @@ public sealed class MainForm : Form
|
||||
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
|
||||
catch { /* baseline failure shouldn't block save */ }
|
||||
unsavedChanges = false;
|
||||
// A freshly created profile has no password yet, and encryption is always on — so
|
||||
// ask for one now and write it straight into the file we just saved. Skipping (empty
|
||||
// or Cancel) leaves it passwordless; the streaming gate will ask again when needed.
|
||||
if (string.IsNullOrEmpty(currentProfilePassword))
|
||||
{
|
||||
var pw = ProfilePasswordDialog.Show(this, title, "");
|
||||
if (!string.IsNullOrEmpty(pw))
|
||||
{
|
||||
currentProfilePassword = pw;
|
||||
RecomputeAudioCrypto();
|
||||
PersistPasswordOnly(pw);
|
||||
AppendLogEntry($"profile password set on creation for \"{title}\"");
|
||||
}
|
||||
}
|
||||
// No confirmation popup here. The Save-As dialog the user just dismissed is itself
|
||||
// the explicit, user-driven "I am saving to this path" — a follow-up "Saved." popup
|
||||
// is pure friction (one more Enter press, one more NVDA read of the same fact).
|
||||
@@ -5040,6 +5189,9 @@ public sealed class MainForm : Form
|
||||
// unsaved-changes prompt would start firing again, and (worse) that prompt could block
|
||||
// an unattended auto-update from restarting. The lock flag must survive every save.
|
||||
profile.ReadOnly = currentProfileReadOnly;
|
||||
// Likewise the encryption password (stored scrambled) — carried through every save so a
|
||||
// routine Save never wipes it (same bug class the ReadOnly line above fixes).
|
||||
profile.Password = RemSoundCrypto.Obfuscate(currentProfilePassword);
|
||||
profile.Volume = volumeBar.Value;
|
||||
profile.Muted = receiver.IsMuted;
|
||||
profile.ReceiveAudioOn = receiveAudioCheckbox.Checked;
|
||||
@@ -5201,6 +5353,179 @@ public sealed class MainForm : Form
|
||||
private static string SerializeProfileForDirtyDiff(Profile profile) =>
|
||||
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
/// <summary>File → Change this profile's password. Shows the current password (plain text,
|
||||
/// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST
|
||||
/// the password back to the profile file straight away — same immediate-persist approach as
|
||||
/// the lock flag, since the password needs to be there next time the profile is loaded.
|
||||
/// Requires a saved profile (a password is meaningless on the blank template, which has no
|
||||
/// file to attach it to). 2026-05-31.</summary>
|
||||
private void ChangeProfilePassword()
|
||||
{
|
||||
if (string.IsNullOrEmpty(currentProfileTitle) || string.IsNullOrEmpty(currentProfilePath))
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"There's no saved profile to attach a password to yet. Save the current setup as a profile first (File → Save as), then set its password.",
|
||||
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
var entered = ProfilePasswordDialog.Show(this, currentProfileTitle, currentProfilePassword);
|
||||
if (entered is null) return; // cancelled
|
||||
currentProfilePassword = entered;
|
||||
RecomputeAudioCrypto();
|
||||
PersistPasswordOnly(entered);
|
||||
AppendLogEntry($"profile password changed for \"{currentProfileTitle}\" (now {(entered.Length == 0 ? "cleared" : "set")})");
|
||||
}
|
||||
|
||||
/// <summary>Write JUST the (scrambled) password back to the profile file, leaving every
|
||||
/// other in-session edit untouched — the same carve-out <see cref="PersistReadOnlyFlagOnly"/>
|
||||
/// uses for the lock flag, so changing the password doesn't silently flush unrelated unsaved
|
||||
/// changes. Read the JSON, set one field, write it back. Blank-template (no path) is a no-op.</summary>
|
||||
private void PersistPasswordOnly(string plaintextPassword)
|
||||
{
|
||||
if (string.IsNullOrEmpty(currentProfilePath)) return;
|
||||
if (!File.Exists(currentProfilePath)) return;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(currentProfilePath);
|
||||
var profile = JsonSerializer.Deserialize<Profile>(json);
|
||||
if (profile is null) return;
|
||||
profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword);
|
||||
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(currentProfilePath, newJson);
|
||||
// Refresh the dirty-diff baseline against the rewritten file so the password change
|
||||
// we just persisted doesn't read back as an unsaved change on close.
|
||||
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
|
||||
catch { /* baseline refresh is best-effort */ }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLogEntry($"failed to persist profile password: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Derive the audio key + fingerprint from the current profile password (cached so
|
||||
/// the slow derivation only runs when the password actually changes) and push them down to
|
||||
/// the sender and receiver. No 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.</summary>
|
||||
private void RecomputeAudioCrypto()
|
||||
{
|
||||
if (currentProfilePassword != lastDerivedPassword)
|
||||
{
|
||||
if (string.IsNullOrEmpty(currentProfilePassword))
|
||||
{
|
||||
currentAudioKey = null;
|
||||
currentAudioFingerprint = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentAudioKey = RemSoundCrypto.DeriveKey(currentProfilePassword);
|
||||
currentAudioFingerprint = RemSoundCrypto.Fingerprint(currentProfilePassword);
|
||||
}
|
||||
lastDerivedPassword = currentProfilePassword;
|
||||
}
|
||||
sender.AudioKey = currentAudioKey;
|
||||
sender.AudioFingerprint = currentAudioFingerprint;
|
||||
receiver.AudioKey = currentAudioKey;
|
||||
receiver.AudioFingerprint = currentAudioFingerprint;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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 label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
|
||||
var entered = ProfilePasswordDialog.Show(this, label, "");
|
||||
if (string.IsNullOrEmpty(entered))
|
||||
{
|
||||
// No password → can't stream. Put the box back without re-firing this gate.
|
||||
suppressStreamingPasswordGate = true;
|
||||
box.Checked = false;
|
||||
suppressStreamingPasswordGate = false;
|
||||
return false;
|
||||
}
|
||||
currentProfilePassword = entered;
|
||||
RecomputeAudioCrypto();
|
||||
// Offer to remember it on the profile (if we're on a saved one).
|
||||
if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath))
|
||||
{
|
||||
var save = MessageBox.Show(this,
|
||||
$"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?",
|
||||
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||
if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Send/receive checkbox handler with the password gate in front of it. Replaces the
|
||||
/// bare HandleCapabilityChange + MarkProfileDirty wiring.</summary>
|
||||
private void OnStreamingCheckboxChanged(AccessibleCheckBox box)
|
||||
{
|
||||
if (suppressStreamingPasswordGate) return;
|
||||
if (!EnsureStreamingPassword(box)) return;
|
||||
HandleCapabilityChange();
|
||||
MarkProfileDirty();
|
||||
}
|
||||
|
||||
/// <summary>Once a second, surface any password mismatch / out-of-date peer the receiver has
|
||||
/// detected from peers' advertised fingerprints — once per change, not every tick — so a
|
||||
/// silent encrypted stream is never an unexplained mystery.</summary>
|
||||
private void CheckPeerSecurity()
|
||||
{
|
||||
foreach (var kv in receiver.GetPeerSecurityStatuses())
|
||||
{
|
||||
var addr = kv.Key;
|
||||
var status = kv.Value;
|
||||
if (!IsSelectedPeerAddress(addr)) continue;
|
||||
if (status is PeerSecurityStatus.Secure or PeerSecurityStatus.Unknown)
|
||||
{
|
||||
lastSecurityWarned.Remove(addr);
|
||||
continue;
|
||||
}
|
||||
if (lastSecurityWarned.TryGetValue(addr, out var warned) && warned == status) continue;
|
||||
lastSecurityWarned[addr] = status;
|
||||
AppendLogEntry($"security: {status} with {addr}");
|
||||
var msg = status == PeerSecurityStatus.PasswordMismatch
|
||||
? $"You and {addr} have different passwords, so no audio will pass between you.\n\nMake sure you've both set the same password (File → Change this profile's password)."
|
||||
: $"{addr} is running an older version of RemSound that can't connect securely. They need to update before audio can flow between you.";
|
||||
MessageBox.Show(this, msg, AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSelectedPeerAddress(System.Net.IPAddress addr) =>
|
||||
selectedPeerEndpoints.Values.Any(e => e.Address.Equals(addr));
|
||||
|
||||
/// <summary>Options → Profile passwords. Opens the password-manager list; if the user changed
|
||||
/// any password, re-sync the active profile's password from disk (the manager wrote it there)
|
||||
/// and re-derive the audio key so the live session uses the new password immediately.</summary>
|
||||
private void OpenProfilePasswordManager()
|
||||
{
|
||||
if (profileStore is null)
|
||||
{
|
||||
MessageBox.Show(this, "Profile system not active in this run.", AppName,
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
var changed = ProfilePasswordManagerDialog.Show(this, profileStore);
|
||||
if (!changed) return;
|
||||
if (!string.IsNullOrEmpty(currentProfilePath) && File.Exists(currentProfilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = JsonSerializer.Deserialize<Profile>(File.ReadAllText(currentProfilePath));
|
||||
currentProfilePassword = RemSoundCrypto.Deobfuscate(profile?.Password);
|
||||
RecomputeAudioCrypto();
|
||||
AppendLogEntry("active profile password refreshed from the password manager");
|
||||
}
|
||||
catch { /* benign — worst case the change applies on next load */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current control state as if the user had just clicked Save.
|
||||
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
|
||||
/// but doesn't write anywhere.</summary>
|
||||
@@ -5451,7 +5776,7 @@ public sealed class MainForm : Form
|
||||
/// Custom paths are per-profile (changed from machine-wide in v3.0.3 development) so
|
||||
/// each profile can carry its own cue palette. The settings cache mirrors the active
|
||||
/// profile's CustomCuePaths dictionary and is the runtime source of truth.</summary>
|
||||
private void TryLoadCueSound(string cueId, string defaultFileName, out System.Media.SoundPlayer? player)
|
||||
private void TryLoadCueSound(string cueId, string defaultFileName, out CuePlayer? player)
|
||||
{
|
||||
player = null;
|
||||
try
|
||||
@@ -5476,9 +5801,9 @@ public sealed class MainForm : Form
|
||||
return;
|
||||
}
|
||||
}
|
||||
var sp = new System.Media.SoundPlayer(path);
|
||||
sp.LoadAsync();
|
||||
player = sp;
|
||||
// CuePlayer reads + plays the file on demand (NAudio), so no pre-load step — and it
|
||||
// copes with any format, including the 96 kHz / 24-bit cue WAVs and custom user files.
|
||||
player = new CuePlayer(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -5521,38 +5846,67 @@ public sealed class MainForm : Form
|
||||
var current = heartbeatService.GetAllPeerHealth();
|
||||
|
||||
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// How recently audio must have arrived to count the peer as "audibly connected". Audio
|
||||
// arrives hundreds of times a second, so a 3-second gap is a genuine interruption, not
|
||||
// jitter. Disconnect also requires the heartbeat to be Unreachable (5 s of no reply), so
|
||||
// the heartbeat is the slower gate for a real, total loss.
|
||||
var audioWindow = TimeSpan.FromSeconds(3);
|
||||
foreach (var ph in current)
|
||||
{
|
||||
var key = $"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}";
|
||||
seenKeys.Add(key);
|
||||
previousPeerHealthStates.TryGetValue(key, out var prior);
|
||||
if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy)
|
||||
|
||||
var audioFlowing = receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, audioWindow);
|
||||
// Connected the moment audio arrives OR the heartbeat is solidly healthy; lost only
|
||||
// when audio has stopped AND the heartbeat has gone unreachable. The middle ground
|
||||
// (heartbeat Stale, or audio briefly paused) holds the previous state — hysteresis,
|
||||
// so a heartbeat blip while audio keeps playing never fires a false disconnect.
|
||||
var isConnected = audioFlowing || ph.State == PeerHealthState.Healthy;
|
||||
var isLost = !audioFlowing && ph.State == PeerHealthState.Unreachable;
|
||||
var wasConnected = peerConnectedState.TryGetValue(key, out var w) && w;
|
||||
|
||||
if (isConnected && !wasConnected)
|
||||
{
|
||||
if (settings.LoadEnableConnectCue()) connectSound?.Play();
|
||||
logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)");
|
||||
var enabled = settings.LoadEnableConnectCue();
|
||||
if (enabled) connectSound?.Play();
|
||||
logFile.Event($"peer connected: {ph.AudioEndpoint} (audio={audioFlowing}, heartbeat={ph.State}) — connect cue {CueOutcome(enabled, connectSound)}");
|
||||
peerConnectedState[key] = true;
|
||||
}
|
||||
else if (ph.State == PeerHealthState.Unreachable
|
||||
&& (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale))
|
||||
else if (isLost && wasConnected)
|
||||
{
|
||||
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
|
||||
logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)");
|
||||
var enabled = settings.LoadEnableDisconnectCue();
|
||||
if (enabled) disconnectSound?.Play();
|
||||
logFile.Event($"peer disconnected: {ph.AudioEndpoint} (audio stopped, heartbeat={ph.State}) — disconnect cue {CueOutcome(enabled, disconnectSound)}");
|
||||
peerConnectedState[key] = false;
|
||||
}
|
||||
else if (!peerConnectedState.ContainsKey(key))
|
||||
{
|
||||
// First sighting and neither clearly connected nor lost (e.g. address typed but
|
||||
// no audio/pong yet) — seed the state without playing a cue.
|
||||
peerConnectedState[key] = isConnected;
|
||||
}
|
||||
previousPeerHealthStates[key] = ph.State;
|
||||
}
|
||||
|
||||
// Peers that vanished from tracking entirely (user deselected). Play disconnect if they
|
||||
// were healthy when last seen.
|
||||
foreach (var key in previousPeerHealthStates.Keys.Where(k => !seenKeys.Contains(k)).ToList())
|
||||
// Peers that vanished from tracking entirely (user deselected). Play disconnect only if
|
||||
// they were connected when last seen — a peer that never connected stays quiet.
|
||||
foreach (var key in peerConnectedState.Keys.Where(k => !seenKeys.Contains(k)).ToList())
|
||||
{
|
||||
if (previousPeerHealthStates[key] == PeerHealthState.Healthy)
|
||||
if (peerConnectedState[key])
|
||||
{
|
||||
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
|
||||
logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)");
|
||||
var enabled = settings.LoadEnableDisconnectCue();
|
||||
if (enabled) disconnectSound?.Play();
|
||||
logFile.Event($"peer disconnected: {key} (deselected while connected) — disconnect cue {CueOutcome(enabled, disconnectSound)}");
|
||||
}
|
||||
previousPeerHealthStates.Remove(key);
|
||||
peerConnectedState.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Describes what actually happened to a cue, for honest logging: "played", "muted
|
||||
/// in settings", or "enabled but sound not loaded" — so the log never claims a cue rang when
|
||||
/// no sound came out. 2026-06-02.</summary>
|
||||
private static string CueOutcome(bool enabled, CuePlayer? sound) =>
|
||||
!enabled ? "muted in settings" : sound is null ? "enabled but sound not loaded" : "played";
|
||||
|
||||
private void NudgeVolume(int deltaPercent)
|
||||
{
|
||||
BeginInvoke(() =>
|
||||
|
||||
@@ -155,6 +155,15 @@ internal sealed class PreferencesDialog : Form
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// After an update installs and RemSound restarts, opening the About box once lets the user
|
||||
// see what changed. Off by default (opt-in). 'h' mnemonic — 'w' is taken by "Write logs now".
|
||||
private readonly AccessibleCheckBox showWhatsNewAfterUpdateBox = new()
|
||||
{
|
||||
Text = "S&how what's new after each update",
|
||||
AccessibleName = "Show what's new after each update",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// UPnP — automatic router port-forwarding via Mono.Nat. Off by default. The status label
|
||||
// is updated live from the RouterPortMapper.StatusChanged event so the user sees the
|
||||
// discovery result inline without having to close and reopen the dialog.
|
||||
@@ -369,6 +378,13 @@ internal sealed class PreferencesDialog : Form
|
||||
cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked;
|
||||
try { cfg.Save(); } catch { /* harmless */ }
|
||||
};
|
||||
showWhatsNewAfterUpdateBox.Checked = cfgForLoad.ShowWhatsNewAfterUpdate;
|
||||
showWhatsNewAfterUpdateBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.ShowWhatsNewAfterUpdate = showWhatsNewAfterUpdateBox.Checked;
|
||||
try { cfg.Save(); } catch { /* harmless */ }
|
||||
};
|
||||
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
|
||||
|
||||
// UPnP toggle — persists immediately and tells MainForm to start / stop the mapper.
|
||||
@@ -445,10 +461,11 @@ internal sealed class PreferencesDialog : Form
|
||||
updateFrequencyBox.TabIndex = 6;
|
||||
checkForUpdatesNowButton.TabIndex = 7;
|
||||
silentlyInstallUpdatesBox.TabIndex = 8;
|
||||
upnpEnabledBox.TabIndex = 9;
|
||||
loggingBox.TabIndex = 10;
|
||||
writeLogsNowButton.TabIndex = 11;
|
||||
closeButton.TabIndex = 12;
|
||||
showWhatsNewAfterUpdateBox.TabIndex = 9;
|
||||
upnpEnabledBox.TabIndex = 10;
|
||||
loggingBox.TabIndex = 11;
|
||||
writeLogsNowButton.TabIndex = 12;
|
||||
closeButton.TabIndex = 13;
|
||||
|
||||
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label
|
||||
// sits inline next to the combo while keeping the combo as the focusable target.
|
||||
@@ -500,10 +517,11 @@ internal sealed class PreferencesDialog : Form
|
||||
panel.Controls.Add(freqRow, 0, 4);
|
||||
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
|
||||
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
|
||||
panel.Controls.Add(upnpEnabledBox, 0, 7);
|
||||
panel.Controls.Add(upnpStatusLabel, 0, 8);
|
||||
panel.Controls.Add(loggingBox, 0, 9);
|
||||
panel.Controls.Add(writeLogsNowButton, 0, 10);
|
||||
panel.Controls.Add(showWhatsNewAfterUpdateBox, 0, 7);
|
||||
panel.Controls.Add(upnpEnabledBox, 0, 8);
|
||||
panel.Controls.Add(upnpStatusLabel, 0, 9);
|
||||
panel.Controls.Add(loggingBox, 0, 10);
|
||||
panel.Controls.Add(writeLogsNowButton, 0, 11);
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
@@ -624,8 +642,9 @@ internal sealed class PreferencesDialog : Form
|
||||
}
|
||||
try
|
||||
{
|
||||
var sp = new System.Media.SoundPlayer(path);
|
||||
sp.Play();
|
||||
// CuePlayer (NAudio) rather than System.Media.SoundPlayer so the preview copes with
|
||||
// any format — including 24-bit / 96 kHz files the basic player can't handle.
|
||||
new CuePlayer(path).Play();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Small dialog for viewing and changing the active profile's encryption password. Shows the
|
||||
/// current password in a PLAIN (readable) edit box, not a masked one, on purpose: this audience
|
||||
/// uses a screen reader, and a masked password field reads as a run of bullets, which is
|
||||
/// useless. Letting NVDA read the actual characters is far more usable, and the security model
|
||||
/// already accepts that the password is recoverable from the profile file anyway.
|
||||
///
|
||||
/// Returns the new password on OK (which may be empty — that clears the password), or null on
|
||||
/// Cancel. The result is trimmed so an invisible trailing space can't cause a maddening
|
||||
/// "we typed the same password but it won't connect" mismatch between two peers. 2026-05-31.
|
||||
/// </summary>
|
||||
internal static class ProfilePasswordDialog
|
||||
{
|
||||
public static string? Show(IWin32Window owner, string profileTitle, string currentPassword)
|
||||
{
|
||||
using var dialog = new Form
|
||||
{
|
||||
Text = "Change profile password",
|
||||
StartPosition = FormStartPosition.CenterParent,
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
MinimizeBox = false,
|
||||
MaximizeBox = false,
|
||||
ShowInTaskbar = false,
|
||||
ClientSize = new Size(460, 150),
|
||||
AccessibleName = "Change profile password",
|
||||
};
|
||||
|
||||
var label = new Label
|
||||
{
|
||||
Text = $"Password for profile “{profileTitle}”:",
|
||||
AutoSize = true,
|
||||
};
|
||||
var textBox = new TextBox
|
||||
{
|
||||
Text = currentPassword,
|
||||
Dock = DockStyle.Top,
|
||||
Width = 400,
|
||||
AccessibleName = $"Password for profile {profileTitle}",
|
||||
};
|
||||
var hint = new Label
|
||||
{
|
||||
Text = "Both you and the person you're connecting to must use the same password.",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK };
|
||||
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||
textBox.KeyDown += (_, args) =>
|
||||
{
|
||||
if (args.KeyCode == Keys.Enter)
|
||||
{
|
||||
dialog.DialogResult = DialogResult.OK;
|
||||
dialog.Close();
|
||||
args.Handled = true;
|
||||
args.SuppressKeyPress = true;
|
||||
}
|
||||
};
|
||||
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), RowCount = 4, ColumnCount = 1 };
|
||||
panel.Controls.Add(label, 0, 0);
|
||||
panel.Controls.Add(textBox, 0, 1);
|
||||
panel.Controls.Add(hint, 0, 2);
|
||||
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
|
||||
buttons.Controls.Add(okButton);
|
||||
buttons.Controls.Add(cancelButton);
|
||||
panel.Controls.Add(buttons, 0, 3);
|
||||
dialog.Controls.Add(panel);
|
||||
dialog.AcceptButton = okButton;
|
||||
dialog.CancelButton = cancelButton;
|
||||
|
||||
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,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>3.2.0</Version>
|
||||
<Version>3.3.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -104,6 +104,20 @@ public sealed class AppConfig
|
||||
/// startup check; the periodic timer (if set) still runs.</summary>
|
||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>If true, RemSound opens the About box (which leads with the latest release
|
||||
/// notes) once on the first launch AFTER an update has been installed, so the user sees
|
||||
/// "what's new" without going looking. Default false — opt-in. Detected by comparing the
|
||||
/// running version against <see cref="LastWhatsNewVersion"/> at launch, so it only fires
|
||||
/// when the version actually changed, never on an ordinary relaunch. On by default — it's a
|
||||
/// discoverability aid (see what changed), not a data-persistence toggle, so the usual
|
||||
/// "auto-options default off" rule doesn't really apply; users can untick it.</summary>
|
||||
public bool ShowWhatsNewAfterUpdate { get; set; } = true;
|
||||
|
||||
/// <summary>The app version recorded at the last launch. Used only to detect "the version
|
||||
/// changed since last run" for <see cref="ShowWhatsNewAfterUpdate"/>. Null until first
|
||||
/// recorded, so a fresh install never counts as an update.</summary>
|
||||
public string? LastWhatsNewVersion { get; set; }
|
||||
|
||||
/// <summary>If true, RemSound tries to open the audio port (UDP 47830) on the local router
|
||||
/// using UPnP / NAT-PMP / PCP, so peers on the public internet can reach this machine
|
||||
/// without manual port forwarding. Default false — the toggle opt-in only, because some
|
||||
|
||||
@@ -36,6 +36,14 @@ public sealed class Profile
|
||||
/// any other in-session edits stay session-only. 2026-05-22.</summary>
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
/// <summary>The profile's encryption password, stored LIGHTLY SCRAMBLED on disk (via
|
||||
/// <see cref="RemSoundCrypto.Obfuscate"/> — not real encryption, just so it isn't legible at
|
||||
/// a glance in a possibly-synced JSON file). Null/empty = no password set yet. Two peers can
|
||||
/// exchange audio only when their profile passwords match, because the audio key is derived
|
||||
/// from this. In memory the running value is the plain text; this field holds the scrambled
|
||||
/// form. Added 2026-05-31 for the always-on encryption feature.</summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
// === Main form: send / receive ===
|
||||
public bool ReceiveAudioOn { get; set; }
|
||||
public bool SendAudioOn { get; set; }
|
||||
|
||||
@@ -83,6 +83,16 @@ public static class RemPacket
|
||||
/// before reading the Lane field; payloads shorter than that default Lane to
|
||||
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
|
||||
public const int FormatPayloadExtendedSize = 36;
|
||||
/// <summary>Format payload size with the 8-byte password fingerprint appended (36 + 8). Sent
|
||||
/// by encryption-capable builds (2026-05-31+) so a peer can tell whether its profile password
|
||||
/// matches ours WITHOUT either side sending the password. Receivers read the fingerprint only
|
||||
/// when <c>payload.Length >= FormatPayloadWithFingerprintSize</c>; shorter payloads (older
|
||||
/// senders) yield a null fingerprint, which the receiver reads as "this peer can't encrypt —
|
||||
/// it needs to update". The fingerprint sits AFTER the 36-byte extended block, so it composes
|
||||
/// with the existing Lane extension and stays backward-compatible.</summary>
|
||||
public const int FormatPayloadWithFingerprintSize = 44;
|
||||
/// <summary>Length of the password fingerprint carried in the extended format payload.</summary>
|
||||
public const int PasswordFingerprintSize = 8;
|
||||
// KeepAlivePayloadSize removed 2026-05-23 — no code reads or writes this payload any more
|
||||
// (see top-of-file comment). RemPacketType.KeepAlive itself is retained for wire safety.
|
||||
/// <summary>
|
||||
@@ -143,7 +153,7 @@ public static class RemPacket
|
||||
/// ignore the trailing 4 — see the <see cref="FormatPayloadSize"/> doc comment for the
|
||||
/// compatibility contract.
|
||||
/// </summary>
|
||||
public static int WriteFormatPayload(Span<byte> destination, AudioFormatInfo format)
|
||||
public static int WriteFormatPayload(Span<byte> destination, AudioFormatInfo format, ReadOnlySpan<byte> passwordFingerprint = default)
|
||||
{
|
||||
if (destination.Length < FormatPayloadExtendedSize)
|
||||
{
|
||||
@@ -168,6 +178,14 @@ public static class RemPacket
|
||||
destination[33] = 0;
|
||||
destination[34] = 0;
|
||||
destination[35] = 0;
|
||||
// Optional 8-byte password fingerprint at offset 36. Written only when the caller
|
||||
// supplies one AND the destination has room — keeps the 36-byte path unchanged for any
|
||||
// caller that doesn't pass a fingerprint.
|
||||
if (passwordFingerprint.Length == PasswordFingerprintSize && destination.Length >= FormatPayloadWithFingerprintSize)
|
||||
{
|
||||
passwordFingerprint.CopyTo(destination.Slice(36, PasswordFingerprintSize));
|
||||
return FormatPayloadWithFingerprintSize;
|
||||
}
|
||||
return FormatPayloadExtendedSize;
|
||||
}
|
||||
|
||||
@@ -197,10 +215,22 @@ public static class RemPacket
|
||||
/// rather than rejected — better to play the audio in the default route than drop a
|
||||
/// stream because a future sender sent an unknown value.
|
||||
/// </summary>
|
||||
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format)
|
||||
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format) =>
|
||||
TryReadFormat(payload, out format, out _);
|
||||
|
||||
/// <summary>As <see cref="TryReadFormat(ReadOnlySpan{byte}, out AudioFormatInfo)"/>, also
|
||||
/// recovering the sender's 8-byte password fingerprint when present (payload long enough).
|
||||
/// <paramref name="passwordFingerprint"/> is null when the sender didn't include one — i.e.
|
||||
/// a pre-encryption build, which the receiver treats as "this peer needs to update".</summary>
|
||||
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format, out byte[]? passwordFingerprint)
|
||||
{
|
||||
format = new AudioFormatInfo(48000, 2, 32, 3, 8, 384000);
|
||||
passwordFingerprint = null;
|
||||
if (payload.Length < FormatPayloadSize) return false;
|
||||
if (payload.Length >= FormatPayloadWithFingerprintSize)
|
||||
{
|
||||
passwordFingerprint = payload.Slice(36, PasswordFingerprintSize).ToArray();
|
||||
}
|
||||
|
||||
var lane = RenderRoute.Mixed;
|
||||
if (payload.Length >= FormatPayloadExtendedSize)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>How a selected peer's encryption lines up with ours, derived from the password
|
||||
/// fingerprint they advertise in their format packets.</summary>
|
||||
public enum PeerSecurityStatus
|
||||
{
|
||||
/// <summary>No fingerprint seen yet (or we have no password set) — nothing to report.</summary>
|
||||
Unknown,
|
||||
/// <summary>Their password fingerprint matches ours: audio will decrypt, the link is secure.</summary>
|
||||
Secure,
|
||||
/// <summary>They advertised a fingerprint, but it differs from ours — different passwords, so
|
||||
/// no audio will pass. The user needs to make the two passwords match.</summary>
|
||||
PasswordMismatch,
|
||||
/// <summary>They sent format packets with no fingerprint at all — an older, pre-encryption
|
||||
/// build. They need to update before audio can flow.</summary>
|
||||
PeerNeedsUpdate,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic helpers for RemSound's always-on audio encryption (in development, 2026-05-31).
|
||||
///
|
||||
/// Model (agreed design): each profile carries a password. Two peers can exchange audio only
|
||||
/// when their profile passwords match, because the audio is encrypted with a key derived from
|
||||
/// the password — same password → same key → each side can unscramble the other; different
|
||||
/// passwords → the integrity check fails and packets are dropped (silence, never garbage).
|
||||
///
|
||||
/// Primitives:
|
||||
/// * <see cref="DeriveKey"/> — turns a password into a 256-bit AES key via PBKDF2 (slow on
|
||||
/// purpose, to make guessing expensive). Run once per password and cached by the caller,
|
||||
/// never per packet.
|
||||
/// * <see cref="Fingerprint"/> — a short, non-reversible id two peers can compare to discover
|
||||
/// they share a password WITHOUT sending it. Different salt from the key so it can't double
|
||||
/// as the key.
|
||||
/// * <see cref="Encrypt"/> / <see cref="TryDecrypt"/> — AES-256-GCM (authenticated) on a
|
||||
/// packet payload. Fast (microseconds, hardware-accelerated). A wrong key fails the auth
|
||||
/// tag and TryDecrypt returns false.
|
||||
/// * <see cref="Obfuscate"/> / <see cref="Deobfuscate"/> — a LIGHT, reversible scramble for
|
||||
/// the password as it sits in the profile JSON. NOT encryption (the key is in the binary):
|
||||
/// it just keeps the password from being readable at a glance in a possibly-synced file.
|
||||
/// That's an accepted trade-off of a portable per-profile password.
|
||||
///
|
||||
/// All algorithms are supported back to Windows 7 (PBKDF2 is pure-managed; AES-GCM goes through
|
||||
/// Windows CNG) — worth verifying on a real Win7 box before this ships, same as the updater.
|
||||
/// </summary>
|
||||
public static class RemSoundCrypto
|
||||
{
|
||||
private const int KeyBytes = 32; // AES-256
|
||||
private const int FingerprintBytes = 8; // enough to compare; not a key
|
||||
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;
|
||||
|
||||
// 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
|
||||
// shared and known in advance. Distinct salts keep the key and the fingerprint independent.
|
||||
private static readonly byte[] KeySalt = Encoding.UTF8.GetBytes("RemSound.v1.audio-key");
|
||||
private static readonly byte[] FingerprintSalt = Encoding.UTF8.GetBytes("RemSound.v1.fingerprint");
|
||||
|
||||
// Repeating-XOR key for the light on-disk scramble (see class summary — NOT security).
|
||||
private static readonly byte[] ObfuscationKey =
|
||||
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
|
||||
|
||||
/// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary>
|
||||
public static byte[] DeriveKey(string? password) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password ?? ""), KeySalt, Pbkdf2Iterations, HashAlgorithmName.SHA256, KeyBytes);
|
||||
|
||||
/// <summary>A short, non-reversible id for a password. Two peers compare fingerprints to
|
||||
/// learn they share a password without revealing it.</summary>
|
||||
public static byte[] Fingerprint(string? password) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password ?? ""), FingerprintSalt, Pbkdf2Iterations, HashAlgorithmName.SHA256, FingerprintBytes);
|
||||
|
||||
/// <summary>Encrypt a payload. Output layout: nonce(12) || tag(16) || ciphertext. A fresh
|
||||
/// random nonce is generated per call. (The live wire layer may later derive the nonce from
|
||||
/// the packet sequence number instead, which is the textbook approach for a long-lived key.)</summary>
|
||||
public static byte[] Encrypt(byte[] key, ReadOnlySpan<byte> plaintext)
|
||||
{
|
||||
var nonce = new byte[NonceBytes];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[TagBytes];
|
||||
using (var aes = new AesGcm(key, TagBytes))
|
||||
{
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
}
|
||||
var output = new byte[NonceBytes + TagBytes + ciphertext.Length];
|
||||
Buffer.BlockCopy(nonce, 0, output, 0, NonceBytes);
|
||||
Buffer.BlockCopy(tag, 0, output, NonceBytes, TagBytes);
|
||||
Buffer.BlockCopy(ciphertext, 0, output, NonceBytes + TagBytes, ciphertext.Length);
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>Reverse <see cref="Encrypt"/>. Returns false (and an empty payload) if the auth
|
||||
/// tag doesn't verify — i.e. the key is wrong or the packet was tampered with.</summary>
|
||||
public static bool TryDecrypt(byte[] key, ReadOnlySpan<byte> packet, out byte[] plaintext)
|
||||
{
|
||||
plaintext = [];
|
||||
if (packet.Length < NonceBytes + TagBytes) return false;
|
||||
var nonce = packet[..NonceBytes];
|
||||
var tag = packet.Slice(NonceBytes, TagBytes);
|
||||
var ciphertext = packet[(NonceBytes + TagBytes)..];
|
||||
var result = new byte[ciphertext.Length];
|
||||
try
|
||||
{
|
||||
using var aes = new AesGcm(key, TagBytes);
|
||||
aes.Decrypt(nonce, ciphertext, tag, result);
|
||||
plaintext = result;
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false; // wrong key or tampered
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The number of bytes <see cref="EncryptInto"/> adds on top of the plaintext
|
||||
/// length (nonce + tag). Callers size their buffers and MTU budgets against this.</summary>
|
||||
public const int EncryptionOverheadBytes = NonceBytes + TagBytes; // 28
|
||||
|
||||
/// <summary>Build a reusable AES-GCM cipher for a key. The caller owns it (it's IDisposable)
|
||||
/// and reuses it across many packets — far cheaper than constructing one per packet. AES-GCM
|
||||
/// is NOT thread-safe, so give each thread (each sender lane; the single receiver thread)
|
||||
/// its own.</summary>
|
||||
public static AesGcm CreateGcm(byte[] key) => new(key, TagBytes);
|
||||
|
||||
/// <summary>Low-allocation encrypt straight into a destination span. Layout written:
|
||||
/// nonce(12) || tag(16) || ciphertext. Returns the number of bytes written
|
||||
/// (= plaintext.Length + <see cref="EncryptionOverheadBytes"/>). <paramref name="dst"/> must
|
||||
/// be at least that big. Generates a fresh random nonce per call (safe at our packet rates).</summary>
|
||||
public static int EncryptInto(AesGcm gcm, ReadOnlySpan<byte> plaintext, Span<byte> dst)
|
||||
{
|
||||
var total = plaintext.Length + EncryptionOverheadBytes;
|
||||
if (dst.Length < total) throw new ArgumentException("Encrypt destination too small", nameof(dst));
|
||||
var nonce = dst[..NonceBytes];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
gcm.Encrypt(nonce, plaintext, dst.Slice(NonceBytes + TagBytes, plaintext.Length), dst.Slice(NonceBytes, TagBytes));
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>Low-allocation decrypt of an <see cref="EncryptInto"/> packet into a destination
|
||||
/// span. Returns true and the plaintext length on success; false if the packet is too short,
|
||||
/// the destination too small, or the auth tag fails (wrong key / tampered).</summary>
|
||||
public static bool TryDecryptInto(AesGcm gcm, ReadOnlySpan<byte> packet, Span<byte> dst, out int written)
|
||||
{
|
||||
written = 0;
|
||||
if (packet.Length < EncryptionOverheadBytes) return false;
|
||||
var ctLen = packet.Length - EncryptionOverheadBytes;
|
||||
if (dst.Length < ctLen) return false;
|
||||
var nonce = packet[..NonceBytes];
|
||||
var tag = packet.Slice(NonceBytes, TagBytes);
|
||||
var ciphertext = packet.Slice(NonceBytes + TagBytes, ctLen);
|
||||
try
|
||||
{
|
||||
gcm.Decrypt(nonce, ciphertext, tag, dst[..ctLen]);
|
||||
written = ctLen;
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false; // wrong key or tampered
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constant-time equality for two fingerprints (or any small byte spans). Avoids
|
||||
/// leaking, via timing, how much of a fingerprint matched.</summary>
|
||||
public static bool FingerprintsEqual(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) =>
|
||||
CryptographicOperations.FixedTimeEquals(a, b);
|
||||
|
||||
/// <summary>Light, reversible scramble of a password for storage in the profile JSON. NOT
|
||||
/// encryption — just so the password isn't legible at a glance. Empty in, empty out.</summary>
|
||||
public static string Obfuscate(string? plaintext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintext)) return "";
|
||||
var data = Encoding.UTF8.GetBytes(plaintext);
|
||||
for (var i = 0; i < data.Length; i++) data[i] ^= ObfuscationKey[i % ObfuscationKey.Length];
|
||||
return Convert.ToBase64String(data);
|
||||
}
|
||||
|
||||
/// <summary>Reverse <see cref="Obfuscate"/>. Returns "" for null/empty/garbage input.</summary>
|
||||
public static string Deobfuscate(string? stored)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stored)) return "";
|
||||
try
|
||||
{
|
||||
var data = Convert.FromBase64String(stored);
|
||||
for (var i = 0; i < data.Length; i++) data[i] ^= ObfuscationKey[i % ObfuscationKey.Length];
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts incoming audio payloads with the key derived from the local profile's password.
|
||||
/// One instance is shared by every <see cref="StreamSession"/>, which is safe because all
|
||||
/// receive-side decode work runs on the single network-listener thread (see StreamSession's
|
||||
/// class summary). The cipher is rebuilt only when the key reference changes (a password
|
||||
/// change), and a single reusable scratch buffer avoids per-packet allocation on the hot path.
|
||||
/// 2026-05-31.
|
||||
/// </summary>
|
||||
internal sealed class AudioDecryptor : IDisposable
|
||||
{
|
||||
private AesGcm? gcm;
|
||||
private byte[]? keyCached;
|
||||
// Sized for the largest decrypted frame (Opus 20 ms / PCM 5 ms are well under this).
|
||||
private readonly byte[] scratch = new byte[8192];
|
||||
|
||||
/// <summary>True once a password/key is set — without one, nothing can be decrypted and all
|
||||
/// audio is dropped (encryption is mandatory).</summary>
|
||||
public bool HasKey => gcm is not null;
|
||||
|
||||
/// <summary>Rebuild the cipher if the key reference changed. Call on the network thread
|
||||
/// before decrypting. Pushing a new array (not mutating in place) is what signals a change.</summary>
|
||||
public void EnsureKey(byte[]? key)
|
||||
{
|
||||
if (ReferenceEquals(key, keyCached)) return;
|
||||
gcm?.Dispose();
|
||||
gcm = key is null ? null : RemSoundCrypto.CreateGcm(key);
|
||||
keyCached = key;
|
||||
}
|
||||
|
||||
/// <summary>Decrypt a ciphertext payload into the shared scratch. Returns the plaintext span
|
||||
/// (a view into the scratch, valid until the next call) or an empty span on failure — wrong
|
||||
/// key (password mismatch), tampered packet, or no key set. Single-threaded use only.</summary>
|
||||
public ReadOnlySpan<byte> TryDecrypt(ReadOnlySpan<byte> ciphertext)
|
||||
{
|
||||
if (gcm is null) return default;
|
||||
return RemSoundCrypto.TryDecryptInto(gcm, ciphertext, scratch, out var len)
|
||||
? scratch.AsSpan(0, len)
|
||||
: default;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
gcm?.Dispose();
|
||||
gcm = null;
|
||||
keyCached = null;
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,54 @@ public sealed class AudioReceiver : IDisposable
|
||||
// OnHeartbeatReceived, regardless of the user's "Receive audio" tick state.
|
||||
private volatile bool playbackEnabled;
|
||||
|
||||
// Audio decryption (2026-05-31). One shared decryptor — all receive decode runs on the
|
||||
// single network thread, so no per-session cipher is needed. AudioKey is the AES key derived
|
||||
// from the local profile's password; AudioFingerprint is the short id of that password we
|
||||
// compare against the fingerprints peers advertise in their format packets. Both are pushed
|
||||
// down by the app and read on the network thread (hence volatile). peerSecurity records the
|
||||
// latest match result per peer address for the app to surface ("passwords don't match" etc.).
|
||||
private readonly AudioDecryptor decryptor = new();
|
||||
private volatile byte[]? audioKey;
|
||||
private volatile byte[]? audioFingerprint;
|
||||
public byte[]? AudioKey { get => audioKey; set => audioKey = value; }
|
||||
public byte[]? AudioFingerprint { get => audioFingerprint; set => audioFingerprint = value; }
|
||||
private readonly object securityLock = new();
|
||||
private readonly Dictionary<IPAddress, PeerSecurityStatus> peerSecurity = new();
|
||||
|
||||
/// <summary>Latest per-peer encryption status (whether their profile password matches ours),
|
||||
/// derived from the fingerprint each peer advertises in its format packets. Keyed by source
|
||||
/// address. The app polls this to tell the user about a password mismatch or an out-of-date
|
||||
/// peer instead of leaving a silent stream a mystery.</summary>
|
||||
public IReadOnlyList<KeyValuePair<IPAddress, PeerSecurityStatus>> GetPeerSecurityStatuses()
|
||||
{
|
||||
lock (securityLock)
|
||||
{
|
||||
return peerSecurity.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if decoded audio from <paramref name="address"/> has been written to a
|
||||
/// playout buffer within <paramref name="within"/>. The app uses this to drive the
|
||||
/// connect/disconnect cues off the ACTUAL audio stream rather than the heartbeat alone —
|
||||
/// so a heartbeat blip while audio keeps flowing never fires a false "disconnect" cue, and
|
||||
/// the connect cue can fire the moment audio starts. Returns false when not receiving (no
|
||||
/// sessions), so the caller falls back to the heartbeat for send-only setups. 2026-05-31.</summary>
|
||||
public bool IsAudioFlowingFrom(IPAddress address, TimeSpan within)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - within;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var session in sessions.Values)
|
||||
{
|
||||
if (session.Endpoint.Address.Equals(address) && session.LastWriteUtc >= cutoff)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allowed-senders gate. The App ticks peer checkboxes; only those endpoints' audio reaches
|
||||
// the playout. A null set means "no filter" (legacy behaviour). An empty set means "block
|
||||
// everyone". Stored as IP addresses (not full IPEndPoint) because incoming packets carry
|
||||
@@ -215,6 +263,30 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// the allow-list. Surfaced via diagnostics so we can confirm the filter is working.</summary>
|
||||
public long PacketsRejectedNotAllowed => Interlocked.Read(ref packetsRejectedNotAllowed);
|
||||
|
||||
private void UpdatePeerSecurity(IPAddress address, byte[]? peerFingerprint)
|
||||
{
|
||||
var myFp = audioFingerprint;
|
||||
PeerSecurityStatus status;
|
||||
if (peerFingerprint is null)
|
||||
{
|
||||
status = PeerSecurityStatus.PeerNeedsUpdate; // peer is on a pre-encryption build
|
||||
}
|
||||
else if (myFp is null)
|
||||
{
|
||||
status = PeerSecurityStatus.Unknown; // we have no password set ourselves yet
|
||||
}
|
||||
else
|
||||
{
|
||||
status = RemSoundCrypto.FingerprintsEqual(peerFingerprint, myFp)
|
||||
? PeerSecurityStatus.Secure
|
||||
: PeerSecurityStatus.PasswordMismatch;
|
||||
}
|
||||
lock (securityLock)
|
||||
{
|
||||
peerSecurity[address] = status;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSenderAllowed(IPEndPoint remote)
|
||||
{
|
||||
var snapshot = allowedSenders;
|
||||
@@ -649,6 +721,7 @@ public sealed class AudioReceiver : IDisposable
|
||||
Stop();
|
||||
listener.Dispose();
|
||||
multiOutput.Dispose();
|
||||
decryptor.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -848,7 +921,7 @@ public sealed class AudioReceiver : IDisposable
|
||||
// honest — disabled-playback drops aren't a malformedness signal.
|
||||
if (!playbackEnabled) return;
|
||||
|
||||
if (!RemPacket.TryReadFormat(payload, out var format))
|
||||
if (!RemPacket.TryReadFormat(payload, out var format, out var peerFingerprint))
|
||||
{
|
||||
Interlocked.Increment(ref packetsDropped);
|
||||
return;
|
||||
@@ -864,6 +937,11 @@ public sealed class AudioReceiver : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Record whether this (selected) peer's profile password matches ours, from the
|
||||
// fingerprint they advertised in this format packet. The app reads this to tell the user
|
||||
// about a password mismatch (or an out-of-date peer) instead of leaving silence a mystery.
|
||||
UpdatePeerSecurity(remote.Address, peerFingerprint);
|
||||
|
||||
SessionPlayout sp;
|
||||
StreamSession? newSession = null;
|
||||
bool isNewSession = false;
|
||||
@@ -907,7 +985,7 @@ public sealed class AudioReceiver : IDisposable
|
||||
isFormatChange = true;
|
||||
}
|
||||
|
||||
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs));
|
||||
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs), decryptor);
|
||||
sessions[key] = newSession;
|
||||
|
||||
// Same-lane streamId rotation: drop other sessions from this peer that share the
|
||||
@@ -994,6 +1072,9 @@ public sealed class AudioReceiver : IDisposable
|
||||
Interlocked.Increment(ref packetsRejectedNotAllowed);
|
||||
return;
|
||||
}
|
||||
// Make sure the decryptor reflects the current profile password before the session
|
||||
// tries to decrypt (cheap reference check; rebuild only happens on a password change).
|
||||
decryptor.EnsureKey(audioKey);
|
||||
StreamSession? session;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ internal sealed class StreamSession : IDisposable
|
||||
private readonly SessionPlayout sessionPlayout;
|
||||
private readonly ReceiverDiagnostics diagnostics;
|
||||
private readonly Action<int> onFramesQueued;
|
||||
private readonly AudioDecryptor decryptor;
|
||||
private readonly PcmFrameAssembler pcmAssembler = new();
|
||||
private IOpusDecoder? opusDecoder;
|
||||
// Sequence-tracking for Opus FEC recovery. uint, so wrap-around is naturally
|
||||
@@ -87,7 +88,8 @@ internal sealed class StreamSession : IDisposable
|
||||
AudioFormatInfo format,
|
||||
SessionPlayout sessionPlayout,
|
||||
ReceiverDiagnostics diagnostics,
|
||||
Action<int> onFramesQueued)
|
||||
Action<int> onFramesQueued,
|
||||
AudioDecryptor decryptor)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
StreamId = streamId;
|
||||
@@ -95,6 +97,7 @@ internal sealed class StreamSession : IDisposable
|
||||
this.sessionPlayout = sessionPlayout;
|
||||
this.diagnostics = diagnostics;
|
||||
this.onFramesQueued = onFramesQueued;
|
||||
this.decryptor = decryptor;
|
||||
|
||||
if (Codec == AudioTransportCodec.Opus)
|
||||
{
|
||||
@@ -211,12 +214,18 @@ internal sealed class StreamSession : IDisposable
|
||||
return true; // pending or dropped due to mismatch — not an error condition
|
||||
}
|
||||
|
||||
// assembled is signed int24 LE, stereo. Convert to float32 and queue.
|
||||
var sampleCount = assembled.Length / 3;
|
||||
// The reassembled frame is ciphertext — decrypt it. An empty result means the peer's
|
||||
// password doesn't match ours (or we have no key): drop silently. The app surfaces the
|
||||
// mismatch from the fingerprint in the format packet, so it isn't a mystery to the user.
|
||||
var assembledPlain = decryptor.TryDecrypt(assembled);
|
||||
if (assembledPlain.IsEmpty) return false;
|
||||
|
||||
// assembledPlain is signed int24 LE, stereo. Convert to float32 and queue.
|
||||
var sampleCount = assembledPlain.Length / 3;
|
||||
var floatBytes = sampleCount * sizeof(float);
|
||||
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||
PcmPack.Int24LEToFloat(assembled, floatSpan);
|
||||
PcmPack.Int24LEToFloat(assembledPlain, floatSpan);
|
||||
|
||||
// Discontinuity probe — what does the audio look like right after we decode it?
|
||||
// Compared to the sender's pre-encode probe, a higher value here would mean the
|
||||
@@ -235,6 +244,12 @@ internal sealed class StreamSession : IDisposable
|
||||
{
|
||||
if (opusDecoder is null) return false;
|
||||
|
||||
// Decrypt the Opus payload up front; both the FEC pass and the normal decode below use
|
||||
// the plaintext. An empty result = wrong password / no key set → drop (silence). The
|
||||
// mismatch is surfaced to the user from the format-packet fingerprint. 2026-05-31.
|
||||
payload = decryptor.TryDecrypt(payload);
|
||||
if (payload.IsEmpty) return false;
|
||||
|
||||
// Frame size in samples-per-channel comes directly off the wire in v3.0+ (was
|
||||
// SampleRate × ms / 1000 in v2.x). Floor at 120 = 2.5 ms = standard libopus
|
||||
// RESTRICTED_LOWDELAY minimum, so a malformed format packet with a tiny value can't
|
||||
|
||||
@@ -92,6 +92,21 @@ public sealed class AudioSender : IDisposable
|
||||
// mode (= 120 samples) can be expressed cleanly. Only meaningful when codec == Opus.
|
||||
private volatile int opusFrameSamples = 480;
|
||||
private volatile bool muted;
|
||||
|
||||
// Audio encryption (2026-05-31). The key is derived from the active profile's password by
|
||||
// the app and pushed down here; the lanes read it on their capture threads (hence volatile)
|
||||
// and rebuild their ciphers when the reference changes. The fingerprint is a short, non-
|
||||
// reversible id of the same password, sent in the format packet so a peer can detect a
|
||||
// password mismatch. Null until a password is set — with no key the lanes send nothing.
|
||||
private volatile byte[]? audioKey;
|
||||
private volatile byte[]? audioFingerprint;
|
||||
/// <summary>The AES key derived from the active profile's password (or null = no password).
|
||||
/// Set by the app; read by the sender lanes. Pushing a new array (not mutating in place)
|
||||
/// is what signals the lanes to rebuild their ciphers.</summary>
|
||||
public byte[]? AudioKey { get => audioKey; set => audioKey = value; }
|
||||
/// <summary>Short non-reversible fingerprint of the active password, advertised in the format
|
||||
/// packet for peer password-match detection. Null = none.</summary>
|
||||
public byte[]? AudioFingerprint { get => audioFingerprint; set => audioFingerprint = value; }
|
||||
private IPEndPoint[] receivers = [];
|
||||
private long packetsSent;
|
||||
private long bytesSent;
|
||||
@@ -630,6 +645,8 @@ public sealed class AudioSender : IDisposable
|
||||
Stop();
|
||||
try { inboundCts?.Cancel(); } catch { /* ignore */ }
|
||||
try { inboundThread?.Join(500); } catch { /* ignore */ }
|
||||
try { defaultLane.DisposeCrypto(); } catch { /* ignore */ }
|
||||
try { asioLane.DisposeCrypto(); } catch { /* ignore */ }
|
||||
engine.Dispose();
|
||||
// Dispose the persistent ASIO LAST, after the engine that was borrowing it. The
|
||||
// composite's Dispose doesn't touch the persistent instance (it borrowed it); we
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
@@ -38,6 +39,16 @@ internal sealed class SenderLane
|
||||
private int frameAccumulatorWritten;
|
||||
private readonly byte[] outboundScratch = new byte[2048];
|
||||
|
||||
// Audio encryption (always on as of the 2026-05-31 encryption feature). Each lane keeps its
|
||||
// OWN AES-GCM cipher because AES-GCM isn't thread-safe and the two lanes run on separate
|
||||
// capture threads. Rebuilt only when the key reference changes (rare — a password change);
|
||||
// null when no password is set, in which case the lane sends nothing (mandatory encryption).
|
||||
// cipherScratch holds the per-frame ciphertext (plaintext + 28 bytes overhead); 4096 covers
|
||||
// the largest single frame (Opus 20 ms or PCM 5 ms) with room to spare.
|
||||
private AesGcm? cryptoGcm;
|
||||
private byte[]? cryptoKeyCached;
|
||||
private readonly byte[] cipherScratch = new byte[4096];
|
||||
|
||||
// Per-stream sequence counters. audioSequence is what the receiver's gap-detector and Opus
|
||||
// FEC look at — it must stay monotonic per stream. formatSequence is used for the periodic
|
||||
// format-announce packet; receiver doesn't sequence-check format packets but having a
|
||||
@@ -262,8 +273,21 @@ internal sealed class SenderLane
|
||||
{
|
||||
PcmPack.FloatToInt24LE(stereoFloats, int24);
|
||||
}
|
||||
EnsureCrypto();
|
||||
if (cryptoGcm is null) return; // no password yet → never send audio in the clear
|
||||
// Encrypt the whole PCM frame, then split the ciphertext across as many parts as the
|
||||
// Ethernet payload budget needs (the +28-byte crypto overhead can push a 5 ms frame over
|
||||
// a single datagram). The receiver reassembles the parts and then decrypts.
|
||||
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, int24, cipherScratch);
|
||||
var maxPart = RemPacket.MaxAudioPayloadBytes;
|
||||
var totalParts = (byte)((ctLen + maxPart - 1) / maxPart);
|
||||
pcmFrameId++;
|
||||
SendPcmPart(pcmFrameId, partIndex: 0, totalParts: 1, int24);
|
||||
for (byte part = 0; part < totalParts; part++)
|
||||
{
|
||||
var offset = part * maxPart;
|
||||
var len = Math.Min(maxPart, ctLen - offset);
|
||||
SendPcmPart(pcmFrameId, part, totalParts, cipherScratch.AsSpan(offset, len));
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitOpusFrame(ReadOnlySpan<float> stereoFloats)
|
||||
@@ -282,7 +306,10 @@ internal sealed class SenderLane
|
||||
if (len <= 0) return;
|
||||
opusBytes = opusEncoder.LastEncoded(len);
|
||||
}
|
||||
SendAudio(opusBytes);
|
||||
EnsureCrypto();
|
||||
if (cryptoGcm is null) return; // no password yet → never send audio in the clear
|
||||
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, opusBytes, cipherScratch);
|
||||
SendAudio(cipherScratch.AsSpan(0, ctLen));
|
||||
}
|
||||
|
||||
// === wire path ===
|
||||
@@ -312,10 +339,34 @@ internal sealed class SenderLane
|
||||
// belongs to. The Lane value carried here comes from the AudioFormatInfo constructed
|
||||
// above, which currently always sets Mixed for the default lane; Stage 4 will set
|
||||
// WasapiLane / AsioLane on the second lane in BothIndependent mode.
|
||||
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadExtendedSize];
|
||||
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadWithFingerprintSize];
|
||||
RemPacket.WriteHeader(packet, RemPacketType.Format, streamId, ++formatSequence);
|
||||
RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format);
|
||||
owner.SendToAll(packet);
|
||||
// Append our password fingerprint so the peer can tell whether its profile password
|
||||
// matches ours without anyone sending the password. WriteFormatPayload returns 36 (no
|
||||
// fingerprint set) or 44 (fingerprint written); we send exactly that many payload bytes.
|
||||
var payloadLen = RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format, owner.AudioFingerprint);
|
||||
owner.SendToAll(packet[..(RemPacket.HeaderSize + payloadLen)]);
|
||||
}
|
||||
|
||||
/// <summary>Rebuild this lane's AES-GCM cipher if the owner's audio key reference changed.
|
||||
/// Cheap reference check on the hot path; the actual rebuild only happens on a password
|
||||
/// change. Null key (no password) leaves the cipher null, which stops the lane sending.</summary>
|
||||
private void EnsureCrypto()
|
||||
{
|
||||
var key = owner.AudioKey;
|
||||
if (ReferenceEquals(key, cryptoKeyCached)) return;
|
||||
cryptoGcm?.Dispose();
|
||||
cryptoGcm = key is null ? null : RemSoundCrypto.CreateGcm(key);
|
||||
cryptoKeyCached = key;
|
||||
}
|
||||
|
||||
/// <summary>Release the AES-GCM cipher's native handle. Called from AudioSender.Dispose so
|
||||
/// the handle doesn't leak on teardown (same native-handle discipline as the Opus encoder).</summary>
|
||||
public void DisposeCrypto()
|
||||
{
|
||||
cryptoGcm?.Dispose();
|
||||
cryptoGcm = null;
|
||||
cryptoKeyCached = null;
|
||||
}
|
||||
|
||||
private void SendPcmPart(uint frameId, byte partIndex, byte totalParts, ReadOnlySpan<byte> partBytes)
|
||||
|
||||
Reference in New Issue
Block a user