diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index b415d4b..c505075 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -9211,7 +9211,8 @@ public sealed class MainForm : Form /// Standard returns the codec's natural frame; Tight halves it (Opus 960 → 480 → 240 → 120 /// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum. /// - private static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate) + // internal so the send-only service host reuses the exact same frame-size rule as the main app. + internal static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate) { if (codec != AudioTransportCodec.Opus) return opusFrameSamples; return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples; diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 40d0304..fc1269e 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -63,6 +63,7 @@ internal static class SelfTest RunStep(results, "Per-application capture lifecycle", AppSendCaptureLifecycle); RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn); RunStep(results, "Service app-yield token", ServiceInteractivePresence); + RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity); RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream); RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); @@ -677,6 +678,39 @@ internal static class SelfTest return "sc create args quoted correctly for a spaced path"; } + /// The service must configure the sender EXACTLY like the main app: derive both the audio key + /// AND the fingerprint from the password (a missing fingerprint gets the encrypted stream rejected at + /// the peer), and apply the send-rate-adjusted Opus frame (the "Small" rate halves it). Guards the + /// divergences found auditing the service against the main app. + private static string? ServiceSenderParity() + { + var profile = new Profile + { + Title = "parity", + Codec = AudioTransportCodec.Opus, + OpusFrameSamplesPerChannel = 960, // broadcast + SendRate = SendRate.Tight, // "Small" — should halve the Opus frame to 480 + WasapiSendMode = "devices", + }; + profile.SelectedWasapiSendOutputs.Add("fake-device-id"); // a source so ApplyProfile proceeds + profile.SelectedConnectedPeers.Add("127.0.0.1:47999"); + const string pw = "hunter2"; + profile.Password = RemSoundCrypto.Obfuscate(pw); + + using var host = new ServiceSendHost(() => profile); + Check(host.ApplyProfile(profile), "ApplyProfile should proceed with a source + peer + password"); + var cfg = host.SenderConfigForTest; + + Check(cfg.Key is { Length: > 0 } && cfg.Key.SequenceEqual(RemSoundCrypto.DeriveKey(pw)), + "the service must set the audio key = DeriveKey(password)"); + Check(cfg.Fingerprint is { Length: > 0 } && cfg.Fingerprint.SequenceEqual(RemSoundCrypto.Fingerprint(pw)), + "the service must set the audio FINGERPRINT = Fingerprint(password), or the peer rejects the stream"); + Check(cfg.Codec == AudioTransportCodec.Opus, "codec must round-trip to the sender"); + Check(cfg.Frame == MainForm.EffectiveOpusFrameSamples(AudioTransportCodec.Opus, 960, SendRate.Tight) && cfg.Frame == 480, + $"the Small send rate must halve the Opus frame like the main app (got {cfg.Frame})"); + return "key + fingerprint + effective Opus frame match the main app"; + } + /// The lock-screen service's app-yield token: while a hold is active the service must see an /// interactive app present; once released (or on crash — the OS frees the mutex) it must see none. /// Uses a unique token name so the test is immune to a real RemSound running alongside the gate. diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 94872db..3ccf9dc 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -60,6 +60,11 @@ public sealed class ServiceSendHost : IDisposable public bool IsSending { get { lock (gate) return running; } } + /// Test seam: the crypto material the host pushed to the sender + the codec/frame it set, so + /// a self-test can prove the service configures the sender exactly like the main app. + internal (byte[]? Key, byte[]? Fingerprint, AudioTransportCodec Codec, int Frame) SenderConfigForTest => + (sender.AudioKey, sender.AudioFingerprint, sender.Codec, sender.OpusFrameSamplesPerChannel); + /// Builds the send sources, peer endpoints and encryption key from a profile and starts the /// sender. Idempotent-ish: call before re-applying a different profile. Returns /// false (and stays stopped) if the profile has nothing to send or no reachable peers. @@ -73,10 +78,16 @@ public sealed class ServiceSendHost : IDisposable if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; } if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; } - sender.AudioKey = string.IsNullOrEmpty(profile.Password) - ? null - : RemSoundCrypto.DeriveKey(RemSoundCrypto.Deobfuscate(profile.Password)); - sender.ConfigureCodec(profile.Codec, profile.OpusFrameSamplesPerChannel); + // Encryption: derive BOTH the key AND the fingerprint from the plain password, exactly like + // MainForm.RecomputeAudioCrypto. The peer verifies the fingerprint before accepting a stream — + // sending the key without it would get the service's audio rejected at the far end. + var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password); + sender.AudioKey = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.DeriveKey(plainPassword); + sender.AudioFingerprint = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.Fingerprint(plainPassword); + // Opus frame size follows the send rate the same way the main app does (the "Small" rate + // halves the Opus frame) — otherwise the service would encode at a different frame than the + // main app would for the identical profile. + sender.ConfigureCodec(profile.Codec, MainForm.EffectiveOpusFrameSamples(profile.Codec, profile.OpusFrameSamplesPerChannel, profile.SendRate)); sender.SetSendRate(profile.SendRate); sender.SetTightLatency(profile.TightLatencyMode); sender.SetReceivers(endpoints); @@ -229,7 +240,9 @@ public sealed class ServiceSendHost : IDisposable catch { addr = null; } } if (addr is null) continue; - var ep = new IPEndPoint(addr, port ?? p.AudioPort); + // Send to the peer's audio port: an explicit "host:port" wins, else the standard peer port — + // the same default the main app's manual-peer path uses (NOT the local listen port). + var ep = new IPEndPoint(addr, port ?? RemPacket.DefaultPeerDialPort); if (seen.Add($"{ep.Address}:{ep.Port}")) result.Add(ep); } return result;