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