Survive changing networks and deepen the receive buffer
Media died silently whenever a client's source address changed. The relay bound a peer's endpoint once and refused to move it, and the client stopped offering its binding token after the first bind, so a Wi-Fi/cellular handover stranded the session in both directions. Add an authenticated Rebind media frame: the binding token travels in the clear for peer lookup only, and the AEAD tag over header and token plus the peer's existing replay window are what authorize the move, so a captured rebind cannot be replayed to redirect someone else's downlink. The client rebuilds its UDP socket instead of retrying on one still pinned to a vanished interface. Nothing judged the control connection live: pings were sent and pongs ignored, so a blackholed TCP path went unnoticed for minutes while the UI showed a live session. Treat any server traffic as liveness and fail the connection when it stops, which drives the existing reconnect. The receive jitter buffer had lost its depth floor, so a channel without FEC or DRED played out with no buffer at all and ordinary reordering became concealment. Restore a one-frame floor, observe every arrival rather than only accepted ones — a shallow buffer was rejecting the late arrivals that should have deepened it — and allow playout to hold a frame so depth can follow a degrading link. A stalled consumer now sheds the oldest queued packet instead of refusing the live talkspurt. Add a deterministic network-impairment simulation covering bursty loss, jitter, reordering, duplication, outages and a stalled consumer, a handover test against a real relay, a replay test for the rebind path, and a blackholed control connection driven through a freezable TCP proxy.
This commit is contained in:
@@ -25,7 +25,11 @@ internal sealed class ReceiveStream : IDisposable
|
||||
private readonly byte[][] jitter;
|
||||
private readonly uint[] timestamps;
|
||||
private readonly int[] sizes;
|
||||
private int count, available, offset, missing, waiting;
|
||||
private int count, available, offset, missing, waiting, stretchCooldown;
|
||||
// One held frame per half second. Depth still follows a degrading link within a few seconds,
|
||||
// but a target pinned at its cap can no longer trade a steady stream of concealment against
|
||||
// depth it will never reach, nor oscillate against CatchUp's trim.
|
||||
private const int StretchCooldownFrames = 25;
|
||||
private uint expected;
|
||||
private bool started, hasTimestamp;
|
||||
private bool hasMarker;
|
||||
@@ -40,6 +44,8 @@ internal sealed class ReceiveStream : IDisposable
|
||||
internal int ConcealedFrames { get; private set; }
|
||||
internal int DredFrames { get; private set; }
|
||||
internal int FecFrames { get; private set; }
|
||||
internal int Overruns { get; private set; }
|
||||
internal int Stretches { get; private set; }
|
||||
|
||||
internal ReceiveStream(uint userId, StreamInfo info, TimeProvider? clock = null)
|
||||
{
|
||||
@@ -60,14 +66,22 @@ internal sealed class ReceiveStream : IDisposable
|
||||
internal bool Enqueue(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int index = written;
|
||||
if (payload.Length is < 1 or > 1275 || unchecked(index - Volatile.Read(ref read)) >= 64) return false;
|
||||
if (payload.Length is < 1 or > 1275) return false;
|
||||
// A stalled consumer (an interrupted or rebuilding iOS graph) stops draining this
|
||||
// handoff. Refusing new packets would hold a ring of audio that is already too old to
|
||||
// play and discard the live talkspurt instead, so the newest always wins and the
|
||||
// consumer — which alone owns `read` — notices the overrun and skips forward.
|
||||
int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length; arrivals[slot] = clock.GetTimestamp();
|
||||
Volatile.Write(ref written, unchecked(index + 1)); return true;
|
||||
}
|
||||
|
||||
private void Drain()
|
||||
{
|
||||
while (read != Volatile.Read(ref written))
|
||||
int end = Volatile.Read(ref written);
|
||||
// Leave a margin below the producer rather than resuming exactly 64 back, so a write
|
||||
// during this drain cannot lap the slot being copied.
|
||||
if (unchecked(end - read) > 64) { read = unchecked(end - 32); Overruns++; }
|
||||
while (read != end)
|
||||
{
|
||||
int source = read & 63; uint timestamp = headers[source].Timestamp;
|
||||
if (!hasTimestamp) { hasTimestamp = true; expected = timestamp; }
|
||||
@@ -78,8 +92,16 @@ internal sealed class ReceiveStream : IDisposable
|
||||
DropBefore(timestamp); available = offset = missing = waiting = 0;
|
||||
expected = timestamp; started = false; delta = 0; lastArrival = 0; jitterSamples = 0;
|
||||
}
|
||||
// Observed before the acceptance test below: arrival statistics describe the network,
|
||||
// not what this buffer could use. Measuring only accepted packets let a buffer that
|
||||
// was too shallow reject the very late arrivals that should have deepened it.
|
||||
ObserveArrival(timestamp, arrivals[source]);
|
||||
bool duplicate = false;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
|
||||
// Frames behind `expected` are unplayable: it is the playout clock and never moves
|
||||
// backward, so a late arrival would sit here forever and, as the oldest entry, would
|
||||
// also mask the future packet that DRED and FEC recover from. Depth, not late
|
||||
// tolerance, is what absorbs reordering here.
|
||||
if ((!started || delta >= 0) && delta % frameSamples == 0 && !duplicate)
|
||||
{
|
||||
if (count >= maximumDepth)
|
||||
@@ -89,7 +111,6 @@ internal sealed class ReceiveStream : IDisposable
|
||||
int target = Array.IndexOf(sizes, 0);
|
||||
timestamps[target] = timestamp; sizes[target] = lengths[source];
|
||||
packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++;
|
||||
ObserveArrival(timestamp, arrivals[source]);
|
||||
}
|
||||
Volatile.Write(ref read, unchecked(read + 1));
|
||||
}
|
||||
@@ -100,8 +121,10 @@ internal sealed class ReceiveStream : IDisposable
|
||||
if (lastArrival != 0)
|
||||
{
|
||||
int timestampDelta = unchecked((int)(timestamp - lastArrivalTimestamp));
|
||||
if (timestampDelta <= 0) return;
|
||||
if (timestampDelta > 0 && timestampDelta <= frameSamples * 10)
|
||||
// A reordered packet arrives with a negative timestamp delta, and that is precisely
|
||||
// the arrival the target depth has to absorb. Ignoring it left reordering invisible
|
||||
// to the estimator. Gaps far beyond a frame are talkspurt silence, not jitter.
|
||||
if (Math.Abs(timestampDelta) <= frameSamples * 10)
|
||||
{
|
||||
double arrivalDelta = clock.GetElapsedTime(lastArrival, arrival).TotalSeconds * 48_000;
|
||||
double deviation = Math.Abs(arrivalDelta - timestampDelta);
|
||||
@@ -111,9 +134,12 @@ internal sealed class ReceiveStream : IDisposable
|
||||
lastArrival = arrival; lastArrivalTimestamp = timestamp;
|
||||
}
|
||||
|
||||
// The playout target never drops below a single frame. A zero target starts playout on one
|
||||
// packet with no depth at all, so ordinary reordering becomes concealment even when nothing
|
||||
// was actually lost; recovery modes need a further frame of lookahead on top of that floor.
|
||||
private int TargetSamples()
|
||||
{
|
||||
int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples : 0;
|
||||
int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples * 2 : frameSamples;
|
||||
int variation = checked((int)Math.Ceiling(4 * jitterSamples / frameSamples)) * frameSamples;
|
||||
return Math.Min(5760, recovery + variation);
|
||||
}
|
||||
@@ -155,6 +181,23 @@ internal sealed class ReceiveStream : IDisposable
|
||||
int found = -1;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == expected) { found = i; break; }
|
||||
bool decoded = false;
|
||||
// The playout clock advances one frame per call, so a target that grows mid-call has no
|
||||
// way to deepen the buffer again. Hold the clock for one frame — concealing instead of
|
||||
// consuming — so the standing depth can follow a link that has become jittery. Only when
|
||||
// the expected frame is actually present, otherwise a loss gap would stall playout, and
|
||||
// CatchUp's trim threshold sits two frames above this so the two cannot oscillate.
|
||||
if (stretchCooldown > 0) stretchCooldown--;
|
||||
if (started && found >= 0 && count > 0 && stretchCooldown == 0)
|
||||
{
|
||||
int ahead = Newest();
|
||||
if (unchecked((int)(timestamps[ahead] - expected)) + frameSamples < TargetSamples())
|
||||
{
|
||||
stretchCooldown = StretchCooldownFrames;
|
||||
if (!decoder.TryDecode([], pcm, frameSamples, out _)) pcm.AsSpan(0, frameSamples * channels).Clear();
|
||||
ConcealedFrames++; Stretches++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (found >= 0)
|
||||
{
|
||||
decoded = decoder.TryDecode(jitter[found].AsSpan(0, sizes[found]), pcm, frameSamples, out int result) && result == frameSamples;
|
||||
|
||||
@@ -10,7 +10,9 @@ public delegate void EncodedVoiceHandler(VoiceFrameHeader header, ReadOnlySpan<b
|
||||
|
||||
internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket socket;
|
||||
private Socket socket;
|
||||
private readonly IPEndPoint endpoint;
|
||||
private readonly byte[] token = new byte[MediaEncryptor.RebindTokenSize];
|
||||
private readonly MediaSessionCrypto crypto;
|
||||
private readonly CancellationTokenSource stop;
|
||||
private readonly byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
@@ -21,6 +23,13 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AutoResetEvent sendReady = new(false);
|
||||
private int senderNapping;
|
||||
private long lastInbound;
|
||||
// A media path that has gone quiet for longer than this is treated as lost: the phone has
|
||||
// most likely changed interface, which strands a connected UDP socket on a dead source
|
||||
// address. Two missed keepalive echoes.
|
||||
private const int RecoveryIdleMilliseconds = 5_000;
|
||||
private const int KeepaliveMilliseconds = 2_000;
|
||||
internal int Migrations { get; private set; }
|
||||
internal event EncodedVoiceHandler? Received;
|
||||
internal Task Bound => bound.Task;
|
||||
|
||||
@@ -28,10 +37,13 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
{
|
||||
if (token.Length != 16) throw new IOException("Invalid UDP binding token.");
|
||||
this.crypto = crypto;
|
||||
this.endpoint = endpoint;
|
||||
token.CopyTo(this.token.AsSpan());
|
||||
stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { socket.Connect(endpoint); }
|
||||
catch { socket.Dispose(); stop.Dispose(); throw; }
|
||||
lastInbound = Environment.TickCount64;
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
token.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
@@ -52,6 +64,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
private void Send()
|
||||
{
|
||||
byte[] plain = new byte[1275], packet = new byte[1275 + VoiceFrameHeader.Size + MediaEncryptor.TagSize];
|
||||
byte[] rebind = new byte[MediaEncryptor.RebindSize];
|
||||
long nextKeepalive = 0;
|
||||
try
|
||||
{
|
||||
@@ -60,23 +73,39 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
bool drained = false;
|
||||
try
|
||||
{
|
||||
// A bound path that stops echoing keepalives has been lost under us. The
|
||||
// socket is connected, so it is still pinned to a source address that may no
|
||||
// longer exist; rebuild it and prove possession of the media key from the
|
||||
// new one so the relay moves this peer's endpoint.
|
||||
if (bound.Task.IsCompleted && Environment.TickCount64 - Volatile.Read(ref lastInbound) > RecoveryIdleMilliseconds)
|
||||
{
|
||||
Rebuild();
|
||||
int size = crypto.Encryptor.EncryptRebind(token, rebind);
|
||||
Volatile.Read(ref socket).Send(rebind.AsSpan(0, size), SocketFlags.None);
|
||||
Migrations++;
|
||||
Volatile.Write(ref lastInbound, Environment.TickCount64);
|
||||
nextKeepalive = 0;
|
||||
}
|
||||
if (Environment.TickCount64 >= nextKeepalive)
|
||||
{
|
||||
if (!bound.Task.IsCompleted) socket.Send(binding, SocketFlags.None);
|
||||
socket.Send(keepalive, SocketFlags.None);
|
||||
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250);
|
||||
Socket current = Volatile.Read(ref socket);
|
||||
if (!bound.Task.IsCompleted) current.Send(binding, SocketFlags.None);
|
||||
current.Send(keepalive, SocketFlags.None);
|
||||
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? KeepaliveMilliseconds : 250);
|
||||
}
|
||||
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
|
||||
{
|
||||
drained = true;
|
||||
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
|
||||
socket.Send(packet.AsSpan(0, size), SocketFlags.None);
|
||||
Volatile.Read(ref socket).Send(packet.AsSpan(0, size), SocketFlags.None);
|
||||
}
|
||||
}
|
||||
catch (SocketException exception) when (IsTransientNetworkError(exception))
|
||||
{
|
||||
// iOS can briefly lose its UDP route while Wi-Fi and cellular switch.
|
||||
// Keep the sender and socket alive so the next route can carry media.
|
||||
// iOS loses its UDP route while Wi-Fi and cellular switch. A connected socket
|
||||
// stays bound to the vanished source address, so retrying on it never
|
||||
// recovers; replace it and let the idle check above re-offer the binding.
|
||||
Rebuild();
|
||||
nextKeepalive = 0;
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
@@ -98,6 +127,18 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
finally { stop.Cancel(); }
|
||||
}
|
||||
|
||||
// Replaces the UDP socket so the next send leaves over whichever interface is now current.
|
||||
// Only the sender thread rebuilds; the receive loop picks the new socket up on its next read.
|
||||
private void Rebuild()
|
||||
{
|
||||
if (stop.IsCancellationRequested) return;
|
||||
Socket replacement = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { replacement.Connect(endpoint); }
|
||||
catch { replacement.Dispose(); return; }
|
||||
Socket previous = Interlocked.Exchange(ref socket, replacement);
|
||||
previous.Dispose();
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
byte[] packet = new byte[65535], plain = new byte[65535];
|
||||
@@ -106,10 +147,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
while (true)
|
||||
{
|
||||
int length;
|
||||
try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
|
||||
Socket current = Volatile.Read(ref socket);
|
||||
try { length = await current.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; }
|
||||
catch (SocketException exception) when (IsTransientNetworkError(exception))
|
||||
{ await Task.Delay(100, stop.Token).ConfigureAwait(false); continue; }
|
||||
// The sender replaced the socket under us during a migration; read the new one.
|
||||
catch (ObjectDisposedException) when (!stop.IsCancellationRequested && !ReferenceEquals(current, Volatile.Read(ref socket))) { continue; }
|
||||
Volatile.Write(ref lastInbound, Environment.TickCount64);
|
||||
if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue;
|
||||
if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; }
|
||||
if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
||||
@@ -128,9 +173,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); socket.Dispose(); sendReady.Set();
|
||||
stop.Cancel(); Volatile.Read(ref socket).Dispose(); sendReady.Set();
|
||||
try { sending.Join(); await receiving.ConfigureAwait(false); }
|
||||
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); sendReady.Dispose(); }
|
||||
finally
|
||||
{
|
||||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding);
|
||||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(token);
|
||||
stop.Dispose(); sendReady.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded, allocation-free packet handoff. A contending producer drops instead of
|
||||
|
||||
@@ -42,6 +42,16 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
private uint adaptiveLossChannel;
|
||||
private int adaptiveLossPercent = -1;
|
||||
private ClientConnectionState state;
|
||||
private long lastControlInbound;
|
||||
// A control connection that stops answering is dead even though the socket still looks open.
|
||||
// A phone that changes interface leaves TCP blackholed rather than reset, and the OS will not
|
||||
// report it for minutes, so liveness is judged here instead.
|
||||
private TimeSpan controlKeepaliveInterval = TimeSpan.FromSeconds(10);
|
||||
private TimeSpan controlSilenceTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Instance scoped so tests can shorten the window without disturbing parallel tests.
|
||||
internal void SetControlLiveness(TimeSpan keepalive, TimeSpan silenceTimeout)
|
||||
{ controlKeepaliveInterval = keepalive; controlSilenceTimeout = silenceTimeout; }
|
||||
|
||||
public event Action<ClientConnectionState>? ConnectionStateChanged;
|
||||
public ClientConnectionState State { get { lock (stateGate) return state; } }
|
||||
@@ -178,8 +188,20 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
|
||||
Volatile.Write(ref lastControlInbound, Environment.TickCount64);
|
||||
using var timer = new PeriodicTimer(controlKeepaliveInterval);
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Every server reply counts as liveness, so a busy session never trips this; an
|
||||
// unanswered ping is what exposes a path that has stopped carrying anything.
|
||||
if (Environment.TickCount64 - Volatile.Read(ref lastControlInbound) > controlSilenceTimeout.TotalMilliseconds)
|
||||
{
|
||||
ConnectionFailure ??= new IOException("Server stopped responding on the control connection.");
|
||||
connectionLifetime?.Cancel();
|
||||
break;
|
||||
}
|
||||
Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or IOException or InvalidOperationException) { }
|
||||
}
|
||||
@@ -212,13 +234,16 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
{
|
||||
await foreach (Envelope message in connection.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
Volatile.Write(ref lastControlInbound, Environment.TickCount64);
|
||||
Apply(message);
|
||||
if (message.RequestId != 0 && pending.TryRemove(message.RequestId, out var completion)) completion.TrySetResult(message.Clone());
|
||||
if (!events.Writer.TryWrite(message.Clone())) throw new IOException("Client event queue exhausted; consume events regularly.");
|
||||
if (message.Disconnect is not null) { connection.CompleteWrites(); break; }
|
||||
}
|
||||
}
|
||||
catch (Exception exception) { failure = exception; ConnectionFailure = exception; }
|
||||
// A liveness failure has already recorded the real cause and cancelled this read, so do
|
||||
// not replace it with the cancellation it produced.
|
||||
catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; }
|
||||
finally
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
|
||||
@@ -25,20 +25,42 @@ public sealed class MediaDecryptor : IDisposable
|
||||
if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext));
|
||||
VoiceFrameHeader.TryRead(packet, out var candidate);
|
||||
ulong sequence = candidate.Sequence;
|
||||
if (initialized && sequence <= highestSequence)
|
||||
{
|
||||
ulong offset = highestSequence - sequence;
|
||||
if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false;
|
||||
}
|
||||
if (IsReplay(sequence)) return false;
|
||||
if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false;
|
||||
Accept(sequence);
|
||||
header = candidate;
|
||||
bytesWritten = length;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only authenticated counters may move the replay window.
|
||||
if (!initialized)
|
||||
{
|
||||
highestSequence = sequence;
|
||||
replayWindow = 1;
|
||||
initialized = true;
|
||||
}
|
||||
// Verifies an endpoint-migration frame: header, plaintext binding token, and tag. The token
|
||||
// is authenticated as additional data, so only the media key holder can move an endpoint,
|
||||
// and the shared replay window makes a captured frame useless to an on-path observer.
|
||||
public bool TryVerifyRebind(ReadOnlySpan<byte> packet, out ReadOnlySpan<byte> token)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
token = default;
|
||||
if (packet.Length != MediaEncryptor.RebindSize) return false;
|
||||
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Rebind) return false;
|
||||
if (IsReplay(candidate.Sequence)) return false;
|
||||
int aad = VoiceFrameHeader.Size + MediaEncryptor.RebindTokenSize;
|
||||
if (!cipher.TryDecrypt(candidate.Sequence, packet[aad..], packet[..aad], [])) return false;
|
||||
Accept(candidate.Sequence);
|
||||
token = packet.Slice(VoiceFrameHeader.Size, MediaEncryptor.RebindTokenSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsReplay(ulong sequence)
|
||||
{
|
||||
if (!initialized || sequence > highestSequence) return false;
|
||||
ulong offset = highestSequence - sequence;
|
||||
return offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0;
|
||||
}
|
||||
|
||||
// Only authenticated counters may move the replay window.
|
||||
private void Accept(ulong sequence)
|
||||
{
|
||||
if (!initialized) { highestSequence = sequence; replayWindow = 1; initialized = true; }
|
||||
else if (sequence > highestSequence)
|
||||
{
|
||||
ulong shift = sequence - highestSequence;
|
||||
@@ -46,9 +68,6 @@ public sealed class MediaDecryptor : IDisposable
|
||||
highestSequence = sequence;
|
||||
}
|
||||
else replayWindow |= 1UL << (int)(highestSequence - sequence);
|
||||
header = candidate;
|
||||
bytesWritten = length;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed class MediaEncryptor : IDisposable
|
||||
private bool disposed;
|
||||
|
||||
public const int TagSize = 16;
|
||||
public const int RebindTokenSize = 16;
|
||||
public const int RebindSize = VoiceFrameHeader.Size + RebindTokenSize + TagSize;
|
||||
|
||||
public MediaEncryptor(ReadOnlySpan<byte> key) : this(key, false) { }
|
||||
|
||||
@@ -31,6 +33,23 @@ public sealed class MediaEncryptor : IDisposable
|
||||
return size;
|
||||
}
|
||||
|
||||
// A rebind frame proves possession of the media key from a new source address. The token
|
||||
// is plaintext so the relay can find the peer without trialling every key; it is covered by
|
||||
// the AEAD as additional data, and the counter makes a captured frame unreplayable.
|
||||
public int EncryptRebind(ReadOnlySpan<byte> token, Span<byte> packet)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
if (token.Length != RebindTokenSize) throw new ArgumentException("Binding tokens contain 16 bytes.", nameof(token));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, RebindSize);
|
||||
if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session.");
|
||||
var header = new VoiceFrameHeader(MediaFrameType.Rebind, VoiceFrameFlags.None, 0, 0, nextSequence++, 0);
|
||||
header.Write(packet);
|
||||
token.CopyTo(packet.Slice(VoiceFrameHeader.Size, RebindTokenSize));
|
||||
int aad = VoiceFrameHeader.Size + RebindTokenSize;
|
||||
cipher.Encrypt(header.Sequence, [], packet[..aad], packet.Slice(aad, TagSize));
|
||||
return RebindSize;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
|
||||
@@ -6,7 +6,10 @@ public enum MediaFrameType : byte
|
||||
{
|
||||
Voice = 1,
|
||||
Keepalive = 2,
|
||||
UdpBinding = 3
|
||||
UdpBinding = 3,
|
||||
// Authenticated endpoint migration. Carries the peer's binding token in the clear for
|
||||
// lookup only; the AEAD tag and replay window are what authorize the move.
|
||||
Rebind = 4
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Channels;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using PacketLossMode = Voicecat.V1.PacketLossMode;
|
||||
|
||||
@@ -31,6 +32,7 @@ internal sealed class MediaRelay : IAsyncDisposable
|
||||
private readonly Channel<byte> changed = Channel.CreateBounded<byte>(1);
|
||||
private MediaRoute[] routes = [];
|
||||
private readonly byte[] input = new byte[65535];
|
||||
private readonly byte[] rebindAck = new byte[VoiceFrameHeader.Size];
|
||||
private readonly MediaFanout fanout = new();
|
||||
private readonly Task receiving;
|
||||
internal Task Completion => receiving;
|
||||
@@ -91,6 +93,30 @@ internal sealed class MediaRelay : IAsyncDisposable
|
||||
foreach (MediaRoute route in current)
|
||||
if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; }
|
||||
|
||||
// A client whose media source address changed (a phone moving between Wi-Fi and
|
||||
// cellular) keeps its TLS session but arrives here from an unknown address. The
|
||||
// binding token alone travels in the clear, so it may only locate the peer; the
|
||||
// authenticated tag and the peer's replay window are what authorize the move.
|
||||
if (header.Type == MediaFrameType.Rebind)
|
||||
{
|
||||
if (length != MediaEncryptor.RebindSize) continue;
|
||||
// Locate the peer by token before verifying, so a flood of forged rebinds
|
||||
// costs one authentication attempt rather than one per connected peer.
|
||||
MediaPeer? claimed = null;
|
||||
foreach (MediaRoute route in current)
|
||||
if (CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, MediaEncryptor.RebindTokenSize)))
|
||||
{ claimed = route.Peer; break; }
|
||||
if (claimed is null || !claimed.Crypto.Decryptor.TryVerifyRebind(input.AsSpan(0, length), out _)) continue;
|
||||
var moved = new SocketAddress(sender.Family, sender.Size);
|
||||
for (int index = 0; index < sender.Size; index++) moved[index] = sender[index];
|
||||
claimed.Endpoint = moved;
|
||||
claimed.Activity.Touch();
|
||||
// Echo a keepalive so the client learns its new path is carrying media.
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(rebindAck);
|
||||
await SendAsync(rebindAck, sender).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.Type == MediaFrameType.UdpBinding)
|
||||
{
|
||||
if (length != VoiceFrameHeader.Size + 16 || source is not null) continue;
|
||||
|
||||
Reference in New Issue
Block a user