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
@@ -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