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