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
+52
View File
@@ -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;
}
}
+83 -2
View File
@@ -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 -4
View File
@@ -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