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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user