Skip to content

Commit 2cfe31b

Browse files
sipsorceryclaude
andauthored
Add SRTCP AES-GCM multi-packet round-trip regression test (#1678)
UnprotectRtcp must derive the per-packet AES-GCM IV from the SRTCP index of the packet being decrypted, not from the connection's highest-seen index (S_l). A single-packet round trip cannot detect a regression here because S_l defaults to 0 and the first index is 0, so they coincide. This test protects and unprotects an in-order multi-packet RTCP stream and asserts every packet round-trips with its payload intact, which only holds when the IV tracks each packet's own index. It guards against the RTCP replay-window refactor in PR #1675 deriving the IV from a stale S_l once the replay update is deferred until after authentication. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4082b0f commit 2cfe31b

1 file changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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

Comments
 (0)