|
| 1 | +//----------------------------------------------------------------------------- |
| 2 | +// Filename: SrtcpGcmMultiPacketUnitTest.cs |
| 3 | +// |
| 4 | +// Description: Regression test for the SRTCP AES-GCM decrypt IV. UnprotectRtcp |
| 5 | +// must derive the per-packet AES-GCM IV from the SRTCP index carried in the |
| 6 | +// packet being decrypted, NOT from the connection's highest-seen index (S_l). |
| 7 | +// |
| 8 | +// This is the RTCP analogue of the concern raised against the replay-window |
| 9 | +// refactor in PR #1675: when the replay window update is (correctly) deferred |
| 10 | +// until after the packet authenticates, S_l no longer equals the current |
| 11 | +// packet's index at IV-generation time. If the IV is taken from S_l it becomes |
| 12 | +// the PREVIOUS packet's index, so every encrypted RTCP packet after the first |
| 13 | +// decrypts with the wrong keystream and AEAD authentication fails. |
| 14 | +// |
| 15 | +// A single-packet round trip cannot catch this (S_l defaults to 0 and the first |
| 16 | +// index is 0, so they coincide). This test sends a multi-packet in-order stream |
| 17 | +// and asserts every packet round-trips and the recovered bytes match, which only |
| 18 | +// holds when the IV tracks the per-packet index. |
| 19 | +// |
| 20 | +// Author(s): |
| 21 | +// Aaron Clauson |
| 22 | +// |
| 23 | +// History: |
| 24 | +// 07 Jun 2026 Aaron Clauson Created. |
| 25 | +// |
| 26 | +// License: |
| 27 | +// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file. |
| 28 | +//----------------------------------------------------------------------------- |
| 29 | + |
| 30 | +using System; |
| 31 | +using SIPSorcery.Net.SharpSRTP.SRTP; |
| 32 | +using Xunit; |
| 33 | + |
| 34 | +namespace SIPSorcery.Net.UnitTests |
| 35 | +{ |
| 36 | + public class SrtcpGcmMultiPacketUnitTest |
| 37 | + { |
| 38 | + [Fact] |
| 39 | + public void MultiPacketRtcpStream_AllPacketsRoundTrip() |
| 40 | + { |
| 41 | + // SRTP_AEAD_AES_128_GCM - the profile WebRTC (and OpenAI's realtime endpoint) negotiates. |
| 42 | + var profile = new SrtpProtectionProfileConfiguration( |
| 43 | + SrtpCiphers.AEAD_AES_128_GCM, |
| 44 | + cipherKeyLength: 128, |
| 45 | + cipherSaltLength: 96, |
| 46 | + maximumLifetime: int.MaxValue, |
| 47 | + auth: SrtpAuth.NONE, |
| 48 | + authKeyLength: 0, |
| 49 | + authTagLength: 128); |
| 50 | + |
| 51 | + // Deterministic key + salt so the test is reproducible. |
| 52 | + var key = new byte[16]; |
| 53 | + var salt = new byte[12]; |
| 54 | + for (int i = 0; i < key.Length; i++) { key[i] = (byte)(0x10 + i); } |
| 55 | + for (int i = 0; i < salt.Length; i++) { salt[i] = (byte)(0x80 + i); } |
| 56 | + |
| 57 | + var sender = new SrtpContext(SrtpContextType.RTCP, profile, key, salt); |
| 58 | + var receiver = new SrtpContext(SrtpContextType.RTCP, profile, key, salt); |
| 59 | + |
| 60 | + const uint ssrc = 0x1234abcdu; |
| 61 | + |
| 62 | + // An in-order stream of several RTCP packets. On the first packet the connection's highest-seen |
| 63 | + // index (S_l) happens to equal the packet index (both 0), so a single packet would pass even with |
| 64 | + // an IV taken from S_l. From the second packet on, the IV must come from the packet's own index; |
| 65 | + // taking it from S_l (the previous packet's index) makes the GCM authentication fail. |
| 66 | + for (int i = 0; i < 5; i++) |
| 67 | + { |
| 68 | + byte marker = (byte)(0xA0 + i); // distinct payload per packet so we verify the decrypt, not just the return code. |
| 69 | + byte[] rtcp = MakeRtcpPacket(ssrc, marker); |
| 70 | + |
| 71 | + int extra = sender.CalculateRequiredSrtcpPayloadLength(rtcp.Length) - rtcp.Length; |
| 72 | + var encrypted = new byte[rtcp.Length + extra]; |
| 73 | + |
| 74 | + int encRc = ProtectRtcp(sender, rtcp, encrypted, out int encLen); |
| 75 | + Assert.True(encRc == 0, $"ProtectRtcp failed for packet {i} (rc={encRc})."); |
| 76 | + |
| 77 | + var encryptedTrimmed = new byte[encLen]; |
| 78 | + Buffer.BlockCopy(encrypted, 0, encryptedTrimmed, 0, encLen); |
| 79 | + |
| 80 | + var decrypted = new byte[encLen]; |
| 81 | + int decRc = UnprotectRtcp(receiver, encryptedTrimmed, decrypted, out int decLen); |
| 82 | + |
| 83 | + Assert.True(decRc == 0, |
| 84 | + $"UnprotectRtcp failed for in-order packet {i} (rc={decRc}). A non-zero result here means the " + |
| 85 | + $"AES-GCM IV did not track the packet's own SRTCP index (e.g. it was taken from S_l, the " + |
| 86 | + $"previous packet's index)."); |
| 87 | + |
| 88 | + Assert.Equal(rtcp.Length, decLen); |
| 89 | + Assert.Equal(rtcp, AsArray(decrypted, decLen)); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + // ---- helpers ---- |
| 94 | + |
| 95 | + private static byte[] MakeRtcpPacket(uint ssrc, byte marker) |
| 96 | + { |
| 97 | + // Minimal well-formed RTCP Receiver Report: 8 byte header (V=2,P=0,RC=0,PT=201,length,SSRC) plus |
| 98 | + // an 8 byte body. Bytes after the first two 32-bit words are the part SRTCP encrypts, so the |
| 99 | + // varying marker lives there to prove the payload was correctly recovered. Length is in 32-bit |
| 100 | + // words minus one: 16 bytes => 4 words => 3. |
| 101 | + var pkt = new byte[16]; |
| 102 | + pkt[0] = 0x80; // V=2, P=0, RC=0 |
| 103 | + pkt[1] = 201; // PT = Receiver Report |
| 104 | + pkt[2] = 0x00; |
| 105 | + pkt[3] = 0x03; // length = (16/4) - 1 |
| 106 | + pkt[4] = (byte)((ssrc >> 24) & 0xFF); |
| 107 | + pkt[5] = (byte)((ssrc >> 16) & 0xFF); |
| 108 | + pkt[6] = (byte)((ssrc >> 8) & 0xFF); |
| 109 | + pkt[7] = (byte)( ssrc & 0xFF); |
| 110 | + for (int i = 8; i < pkt.Length; i++) { pkt[i] = marker; } |
| 111 | + return pkt; |
| 112 | + } |
| 113 | + |
| 114 | + private static byte[] AsArray(byte[] buffer, int length) |
| 115 | + { |
| 116 | + var trimmed = new byte[length]; |
| 117 | + Buffer.BlockCopy(buffer, 0, trimmed, 0, length); |
| 118 | + return trimmed; |
| 119 | + } |
| 120 | + |
| 121 | +#if NET8_0_OR_GREATER |
| 122 | + private static int ProtectRtcp(SrtpContext ctx, byte[] input, byte[] output, out int outLen) |
| 123 | + => ctx.ProtectRtcp(input.AsSpan(), output.AsSpan(), out outLen); |
| 124 | + |
| 125 | + private static int UnprotectRtcp(SrtpContext ctx, byte[] input, byte[] output, out int outLen) |
| 126 | + => ctx.UnprotectRtcp(input.AsSpan(), output.AsSpan(), out outLen); |
| 127 | +#else |
| 128 | + private static int ProtectRtcp(SrtpContext ctx, byte[] input, byte[] output, out int outLen) |
| 129 | + => ctx.ProtectRtcp(new ArraySegment<byte>(input), output, out outLen); |
| 130 | + |
| 131 | + private static int UnprotectRtcp(SrtpContext ctx, byte[] input, byte[] output, out int outLen) |
| 132 | + => ctx.UnprotectRtcp(new ArraySegment<byte>(input), output, out outLen); |
| 133 | +#endif |
| 134 | + } |
| 135 | +} |
0 commit comments