Networking & media Real-time voice, from the packet up

Keys, then audio

DTLS, SRTP and one call from cold start, measured.

On this page · 10 sections

Post 3 left Alice with a working path: her laptop’s packets get from 192.0.2.10:54321 to the assistant’s server at 198.51.100.20:50004, and back. So her 120-byte packet from post 1 can travel it. The trouble is that it travels it as it is, and its 80 bytes of Opus are sound, not secret. Any router on the way could hand them to a decoder and listen.

WebRTC doesn’t allow that. Its security architecture says it flatly: “Media traffic MUST NOT be sent over plain (unencrypted) RTP or RTCP” (RFC 8827 §6.5). So before the first 20 ms of sound, two things have to exist that don’t yet: keys that both ends know and nobody else does, and a way to use them on fifty packets a second without slowing any of them down. We’ll see how Alice’s browser and the assistant’s server get both, what encryption actually does to her packet, and, measured, how long the whole call takes to come up from nothing.

The short version

  • A call runs on two channels. The signalling channel carries the SDP; the media path carries the handshake and the audio. No key travels on either.
  • The SDP carries a fingerprint of each side’s certificate, never a key.
  • DTLS is a handshake on the media path: two round trips, after which both sides share a secret nobody watching could work out, and each works out the same audio keys from it, one set per direction.
  • SRTP encrypts the payload, leaves the RTP header readable, and adds a 10-byte tag: Alice’s packet goes from 120 bytes to 130.
  • Measured, a call needs about three round trips before the first sound: one to check the path, two for the handshake.

Terms we’ll lean on, each explained where it first matters:

TermIn one line
SDPThe text two sides exchange to set up a call: codecs, ICE details, and the fingerprint
DTLSTLS rebuilt to survive UDP’s loss and reordering
SRTPSecure RTP: encryption and an authentication tag on every media packet
FingerprintA hash of a certificate, sent in the SDP so the other side can recognise the certificate when DTLS shows it
ExporterA DTLS feature that turns a finished handshake into extra key material, here for SRTP
Key, saltWhat SRTP gets for each direction: the key encrypts, and the salt is a second random value mixed into every packet so no two come out alike

Two channels, and what each one carries#

A WebRTC call has two separate paths, and it matters which one carries what.

The signalling channel is whatever the application chooses. JSEP, the standard for how browsers set up calls, leaves it “entirely up to the application” (RFC 9429 §3.1); a WebSocket over HTTPS to the assistant’s web service is typical. It carries one SDP offer from Alice and one answer back. The media path is the UDP flow ICE found in post 3, and it carries three protocols, starting in this order: the STUN checks, then the DTLS handshake, then SRTP.

Alice's browser at 192.168.1.42 behind her router at 192.0.2.10, the app's signalling server, and the assistant's server at 198.51.100.20:50004. A dashed signalling path, chosen by the app (for example a WebSocket over HTTPS), carries the SDP offer (ufrag alce, candidates, a=fingerprint, a=setup:actpass) and the answer (ufrag srv7, a=ice-lite, a=fingerprint, a=setup:active). A solid media path, one UDP flow from 192.0.2.10:54321 to 198.51.100.20:50004, carries in order STUN checks, the DTLS handshake (where the certificates cross) and SRTP. No key travels on either path.
The key is never on either path: the fingerprint on one vouches for the certificate on the other.

The keys are made on the media path, not sent on either. What the signalling channel carries, for security, is only a fingerprint. RFC 5763: “the entire authentication and key exchange for securing the media traffic is handled in the media path through DTLS. The signaling path is only used to verify the peers’ certificate fingerprints” (§5).

Why not just put the key in the SDP? There is a standard for exactly that, SDP security descriptions, and WebRTC forbids it: implementations “MUST NOT offer SDP security descriptions [RFC4568] or select it if offered” (RFC 8827 §6.5). The signalling passes through the application’s servers, its logs and the page’s own JavaScript. A key written there can be read by any of them, quietly, and used to decrypt the call later. A fingerprint written there is useless to someone who only reads it; to attack the call they’d have to rewrite the SDP in transit and then sit on the media path as well, which is a much bigger job than reading a log.

Alice’s offer, line by line#

Here’s Alice’s offer, as the demo builds it in the layout of JSEP’s own example, with the candidates, ufrag and SSRC from the earlier posts:

text
sdp/alice-offer.sdp
v=0
o=- 4611731400430051336 2 IN IP4 0.0.0.0
s=-
t=0 0
a=group:BUNDLE 0
a=ice-options:trickle ice2
m=audio 50004 UDP/TLS/RTP/SAVPF 111
c=IN IP4 192.168.1.42
a=mid:0
a=sendrecv
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1
a=rtcp-fb:111 transport-cc
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:2 urn:ietf:params:rtp-hdrext:sdes:mid
a=msid:alice-mic alice-mic-track
a=ssrc:523124044 cname:alice
a=ice-ufrag:alce
a=ice-pwd:alice+password+23+chars
a=fingerprint:sha-256 A9:5C:8D:BA:4C:00:E3:59:80:B8:8E:45:F1:DA:8E:5C:5D:01:AB:C7:32:0C:89:69:94:EE:DC:72:61:A1:00:DD
a=setup:actpass
a=tls-id:1f2e3d4c5b6a79880a1b2c3d4e5f6071
a=rtcp-mux
a=rtcp-rsize
a=candidate:1 1 udp 2130706431 192.168.1.42 50004 typ host
a=candidate:2 1 udp 1694498815 192.0.2.10 54321 typ srflx raddr 192.168.1.42 rport 50004
a=candidate:3 1 udp 16777215 198.51.100.50 49152 typ relay raddr 192.0.2.10 rport 54321
a=end-of-candidates

Twenty-eight lines, and most of them are for things earlier posts covered. Grouped by the part of the call that reads them:

LinesRead byWhat they set
v=, o=, s=, t=Session bookkeepingVersion 0, an ID, no name, no time limits
a=ice-ufrag, a=ice-pwd, the candidates, c=ICE, post 3The credentials that sign every check, and every address Alice might be reached at
a=group:BUNDLE 0, a=rtcp-muxThe transportEverything on one port: audio, its RTCP, and any later media (RFC 9143, RFC 5761)
a=rtpmap, a=fmtpThe codecOpus, payload type 111, with in-band FEC from post 2
a=rtcp-fb ... transport-ccFeedback, post 5Per-packet feedback for congestion control
a=fingerprint, a=setup, a=tls-idDTLSWhich certificate to expect, and who may start the handshake

Two things are easy to misread. opus/48000/2 doesn’t mean stereo: RFC 7587 says the clock rate “MUST be 48000, and the number of channels MUST be 2” for every Opus session, and stereo is a separate parameter that defaults to mono (§7). And UDP/TLS/RTP/SAVPF on the m= line is the profile name for DTLS-SRTP, the one trace SRTP leaves in the whole description.

The point to take away is the line that isn’t there: no line carries a key. There’s a fingerprint, a hash of a certificate Alice will show later, and a=setup:actpass, which offers either DTLS role and leaves the choice to the answerer.

The server’s answer is shorter: a=ice-lite, one host candidate, its own fingerprint, and a=setup:active. JSEP says an answerer “SHOULD use the “active” role” when the offer says actpass (RFC 9429 §5.3.1), and RFC 5763 says why: active “allows the answer and the DTLS handshake to occur in parallel”. So the assistant’s server will be the one that starts the handshake.

DTLS borrows TLS’s names for the two roles, and they’re the opposite of what you’d guess here, so it’s worth fixing them now:

RoleAlice’s browserThe assistant’s server
DTLS roleDTLS server: waits for the handshake to startDTLS client: starts it, because its answer said active
Sends its audio withthe server_write keythe client_write key

From here on, “the server” in a sentence about DTLS still means the assistant’s machine, and when the role matters the role is named.

Paste any SDP into the explainer and it sorts every line the same way, says which part of the call reads it, and checks it against the RFCs. The samples include Alice’s offer, the server’s answer, a real stack’s answer, and a broken offer with five mistakes:

SDP explainer/tools/sdp-explainer/

The fingerprint is a hash of a certificate. So when does anyone actually see the certificate?

A handshake that survives UDP: DTLS#

The certificate shows up in the handshake, on the media path. Here’s the idea before the details. Each side has a certificate: a public key, made fresh for the call, which only its owner can prove it holds. Unlike HTTPS, no certificate authority signs it; what makes it trustworthy is the fingerprint the other side already got in the SDP. The SDP already told each side the other’s fingerprint. In the handshake each side shows its certificate, proves it owns it, and the two of them agree on a shared secret in a way that someone watching every byte still can’t work out. That last trick is ordinary public-key key exchange, the same one HTTPS uses.

What isn’t ordinary is the transport. TLS, the protocol behind HTTPS, assumes TCP’s reliable, ordered delivery, and the media path is UDP, where packets get lost and reordered. RFC 6347: “TLS has no internal facilities to handle this kind of unreliability” (§3). DTLS is TLS with what’s missing added back: it numbers its messages, resends any that don’t get an answer, and splits big ones across several datagrams.

The demo runs a real call between two peers built with aiortc, a Python implementation of WebRTC, records every packet, and decodes the handshake. It’s four bursts of messages, called flights, two round trips in all:

A sequence diagram, the server on the left as DTLS client (because the answer said a=setup:active) and Alice on the right as DTLS server. Flight 1, 173 B: ClientHello, sent at 109.5 ms. Flight 2, 730 B: ServerHello, Certificate, ServerKeyExchange, CertificateRequest, ServerHelloDone, at 162.0 ms. Flight 3, 659 B: Certificate, ClientKeyExchange, CertificateVerify, ChangeCipherSpec, Finished, at 214.6 ms. Flight 4, 668 B: NewSessionTicket, ChangeCipherSpec, Finished, at 268.1 ms. Alice is keyed at 268.6 ms, after sending flight 4, and sends her first SRTP at 274.6; the server is keyed at 320.3 ms, when flight 4 lands. A greyed box: the cookie exchange, HelloVerifyRequest and a second ClientHello, was not sent here, though RFC 6347 says a server SHOULD do it by default. Times from one call with 50 ms added each way.
Four flights and two round trips. Each side is ready to encrypt once it has seen the other’s last message.
FlightWho sends itWhat it does
1The assistant’s server (DTLS client)“Hello”: the versions and ciphers it can use
2Alice (DTLS server)Her choice of cipher, her certificate, her half of the key exchange, and a request for the other side’s certificate
3The assistant’s serverIts certificate, proof that it owns it, its half of the key exchange, and “done”
4Alice”Done” from her side

One loopback call, DTLS 1.2: about 2,230 bytes in four packets.

Then each side compares the certificate it was shown with the fingerprint from the SDP. If they don’t match, RFC 5763 says the endpoint “MUST tear down the media session immediately”. The demo tries it: change one character of the answer’s fingerprint and Alice’s side ends in failed and sends nothing.

That makes the fingerprint the whole of the trust. Whoever can rewrite the SDP on its way could swap in their own fingerprint and their own certificate, which is why the signalling channel has to be protected in its own right, with HTTPS.

The handshake in detail: the twelve messages, the missing cookie, and DTLS 1.3

Inside the four flights are twelve handshake messages and two “change cipher” records: ClientHello; then ServerHello, Certificate, ServerKeyExchange, CertificateRequest, ServerHelloDone; then Certificate, ClientKeyExchange, CertificateVerify, ChangeCipherSpec, Finished; then NewSessionTicket, ChangeCipherSpec, Finished. Flight sizes were 173, 727 to 730, 659 and 668 bytes across the runs; the figure’s 730 is from the run with a delay added.

It could have been six flights. RFC 6347 says a DTLS server “SHOULD perform a cookie exchange” by default (§4.2.1), an extra round trip that stops anyone using it to flood a forged address with certificates. This one didn’t.

The demo negotiates DTLS 1.2, because that’s what aiortc’s OpenSSL offers. Chrome added DTLS 1.3 for WebRTC in Chrome 137; before that, its release notes say, “DTLS 1.2 was used for all WebRTC connections” (release notes). Between two Chrome connections it’s 1.3: in Chrome 154 on macOS the lab below reports FEFC, DTLS 1.3.

One port, three protocols#

STUN checks, DTLS and the encrypted audio all arrive on the same UDP port, so the receiver needs a way to tell them apart, and it looks at the first byte: 0 to 3 is STUN, 20 to 63 is DTLS, 128 to 191 is audio or its reports (RFC 7983 §7). It works because each protocol’s first byte happens to fall in its own range already.

A datagram arriving on port 50004 is sorted by its first byte, as RFC 7983 section 7 sets out: 0 to 3 is STUN, 20 to 63 is DTLS, 128 to 191 is RTP or RTCP, and anything else is dropped. Beside each, a first byte from the demo's call: STUN 00 01, a Binding request (2 requests and 2 responses in the call); DTLS 16, which is 22, handshake (4 datagrams, 4 flights); SRTP 80, version 2 as in post 1's header. RTCP is told apart from RTP by the second byte (RFC 5761 section 4).
One byte says which protocol a packet belongs to.
The demo's classifier
python
call_timeline.py
def classify(data: bytes) -> str:
first = data[0]
if first <= 3:
return "STUN"
if 20 <= first <= 63:
return "DTLS"
if 128 <= first <= 191:
return "SRTCP" if 192 <= data[1] <= 223 else "SRTP" # RTCP packet types sit where RTP's marker+PT would
return "?"

The RFC also reserves 16 to 19 for ZRTP and 64 to 79 for TURN channels, which this call doesn’t use; anything outside the ranges is dropped. RTCP shares RTP’s range and is told apart by the second byte, which post 5 picks up.

Where the keys come from#

At the end of the handshake, Alice’s browser and the server share a secret that nobody watching could work out. The audio keys come from that secret, and they’re never sent anywhere: each side works them out for itself and gets the same answer.

DTLS can stretch its secret into as many extra bytes as you ask for, a feature called the exporter (RFC 5764 §4.2). WebRTC asks for 60 bytes and cuts them into four pieces:

  • a key and a salt for everything the server sends, and
  • a key and a salt for everything Alice sends.

The key is what encrypts. The salt is a second random value mixed into every packet’s encryption, so that even two packets with the same content come out differently.

The DTLS handshake feeds a TLS exporter with the label EXTRACTOR-dtls_srtp, which gives 60 bytes, never sent, cut at 0, 16, 32 and 46: client master key 16 B, server master key 16 B, client salt 14 B, server salt 14 B. The client key and salt go into one SRTP key derivation and the server key and salt into another. Each gives six values: SRTP encryption key 16 B (label 0x00), authentication key 20 B (0x01), salt 14 B (0x02), and SRTCP's three (0x03 to 0x05). The client set protects the server's packets, since it is the DTLS client; the server set protects Alice's.
Never on the wire, and the page’s own JavaScript can’t read them either (RFC 8827 §6.5).

Why a separate key for each direction? Because the encryption SRTP uses works by mixing the audio with a stream of pseudo-random bytes, and the one rule it can’t break is using the same stream twice: RFC 3711 calls that a “two-time pad” and warns it “can seriously compromise security” (§9.1). Alice and the server both send packets numbered from whatever they chose, at the same time, without coordinating. Giving each direction its own key means their streams can never collide, whatever numbers they pick.

The cut in code, and the twelve working keys
python
keys.py
cut = {
"client_write_SRTP_master_key": material[0:KEY],
"server_write_SRTP_master_key": material[KEY : 2 * KEY],
"client_write_SRTP_master_salt": material[2 * KEY : 2 * KEY + SALT],
"server_write_SRTP_master_salt": material[2 * KEY + SALT :],
}

Key, key, salt, salt: 16, 16, 14 and 14 bytes, in that order, not key-salt-key-salt. “Client” and “server” here mean the DTLS roles, and Alice is the DTLS server, so she sends with the server_write pair. The demo checks that on a real packet: Alice’s first audio packet decrypts with the server_write half and is rejected with the other. Each pair then goes through SRTP’s own key derivation and becomes six working keys: one to encrypt, one to sign and one salt, for the audio and again for its reports. Twelve in all, from one handshake. (Those sizes are for the profile keys.py asks for; left to choose, two aiortc peers pick an AES-256-GCM profile and export 88 bytes.)

Alice’s packet, encrypted#

The demo takes post 1’s packet straight from post 1’s capture, gives SRTP a fixed, fake master key, and protects it with SRTP_AES128_CM_HMAC_SHA1_80, the profile every WebRTC endpoint “MUST” support (RFC 8827 §6.5):

RTP header12 bytes80 6f 03 f2 00 00 25 80 1f 2e 3d 4cExactly as it was. Version 2, payload type 111, sequence 1010, timestamp 9600, SSRC 0x1F2E3D4C. Anyone on the path can still read all of it.
Opus, encrypted80 bytes62 23 76 62 5a f9 71 28 … 72 moreThe sound, unreadable. All 80 bytes changed; before, they began 4b 41 10 98 fe 7e b5 24. Same length: the encryption adds no padding.
Authentication tag10 bytesa3 29 b9 90 13 a1 c5 52 0a 5dNew. HMAC-SHA1 over the header and the encrypted payload, plus a counter that's never sent, cut to 80 bits.
SRTP102 bytes92 bytes of RTP become 102 of SRTP; with IPv4 and UDP, post 1's 120-byte packet becomes 130

The whole cost on the wire is the tag: 10 bytes a packet.

SRTP has more than one recipe, called a profile, and the two sides agree on one during the handshake. The one shown here is the profile every WebRTC endpoint must support: it encrypts with AES and then signs with a separate 10-byte tag. A newer profile, AES-GCM (RFC 7714), does the encrypting and signing in one step and uses a 16-byte tag, so the same packet would be 108 bytes instead of 102. You don’t need to pick one yourself; the browser does. Chrome 154 on macOS picked the 10-byte one in the lab below, and the lab shows what yours picks.

How does a key turn 80 bytes of sound into 80 bytes of noise? The demo doesn’t take the SRTP library’s word for it. It rebuilds the packet from RFC 3711 alone and gets the same 102 bytes, and the whole recipe is ten lines:

python
srtp_one_packet.py
k_e = kdf(master_key, master_salt, 0x00, 16)
k_a = kdf(master_key, master_salt, 0x01, 20)
k_s = kdf(master_key, master_salt, 0x02, 14)
seq, ssrc = struct.unpack("!H", packet[2:4])[0], struct.unpack("!I", packet[8:12])[0]
index = (roc << 16) | seq # RFC 3711 section 3.3.1: i = 2^16 * ROC + SEQ
iv = (int.from_bytes(k_s) << 16) ^ (ssrc << 64) ^ (index << 16) # section 4.1.1
header, payload = packet[:12], packet[12:]
keystream = aes_cm(k_e, iv.to_bytes(16), len(payload))
encrypted = bytes(a ^ b for a, b in zip(payload, keystream))
tag = hmac.new(k_a, header + encrypted + struct.pack("!I", roc), hashlib.sha1).digest()[:10] # section 4.2
return header + encrypted + tag

Read it in four steps:

  1. Three working keys come out of the key and salt DTLS gave this direction: one to encrypt (k_e), one to sign (k_a), and a salt (k_s).
  2. A starting point for this packet (iv) is built from the salt, the stream’s ID and the packet’s number. Every packet gets a different one, so no two packets are ever encrypted the same way.
  3. The payload is mixed with pseudo-random bytes made from the key and that starting point (keystream), one byte for one byte, so the sound is scrambled and stays 80 bytes long.
  4. The tag is a signature over the header and the scrambled payload, cut to 10 bytes. The header itself is the first 12 bytes in and the first 12 bytes out.

What the receiver refuses#

So what stops someone on the path from changing a packet, or sending one again? The demo tries it. A first receiver with the matching keys gets the packet twice; a second, fresh one gets three tampered copies and then the original; a third, holding the other direction’s keys, gets the original too:

ReceiverPacketResult
FirstThe packet as sentAccepted, payload restored
FirstThe same packet againRejected: replay
SecondOne bit flipped in the payloadRejected: authentication failure
SecondOne bit flipped in the sequence numberRejected: authentication failure
SecondOne bit flipped in the tagRejected: authentication failure
Third, with the other direction’s keysThe packet as sentRejected: authentication failure
SecondThe original, after all those failuresAccepted

The sequence-number row is the interesting one: the header is readable, but it isn’t changeable, because it’s inside what the tag covers. The replay row is SRTP’s replay list, a window of at least 64 recent packets (§3.3.2): an authentic packet is accepted exactly once. And the last row shows the order of the receiver’s work: three forgeries in a row didn’t change what the second receiver would accept. RFC 3711 has it check for a replay, then check the tag, then decrypt, and only then update its state (§3.3), so a forged packet can’t move anything.

Five steps in order: estimate the packet's index (ROC), replay check, tag check, decrypt, update state. Seven test packets: 1 and 2 go to one receiver, 3, 4, 5 and 7 to a fresh one, and 6 to one holding the other direction's keys. Packet 2, the same packet again, is rejected at the replay check. Packets 3 to 6, a flipped payload bit, a flipped sequence-number bit, a flipped tag bit and the other direction's keys, are rejected at the tag check with an authentication failure. Packets 1, as sent, and 7, the original sent after 3 to 5 failed on the same receiver, are accepted and their payload restored, because a rejected packet changes no state.
Nothing is decrypted before it is authenticated.

One detail from the code: packet numbers are only 16 bits, so they wrap back to 0 every 65,536 packets, about 22 minutes of Alice talking. Both sides count the wraps themselves, in a rollover counter that’s never sent but goes into every packet’s encryption and tag. The demo sends packet 65535 and then packet 0, and the receiver accepts both, having counted the wrap on its own.

Why the header stays readable#

If the header is authenticated anyway, why not encrypt it too? RFC 3711’s reason is plain: “RTP headers are sent in the clear to allow for header compression. This means that data such as payload type, synchronization source identifier, and timestamp are available to an eavesdropper” (§9.4). A later RFC adds that some fields have to stay readable anyway, because “they are used for key scheduling (e.g., RTP synchronization source (SSRC) and sequence number)” (RFC 9335 §1.1): the receiver needs them to know which key and counter to use before it can decrypt anything.

It has a cost a voice post should name. Alice’s offer has an ssrc-audio-level extension: if it’s negotiated, each packet carries one byte saying how loud that 20 ms is, in an 8-byte header extension. (Post 1’s packet was built without extensions to keep its bytes simple, which is why its header is a bare 12 bytes.) It rides in the clear too, and the RFCs call that sensitive, since a loudness trace can give away when someone is speaking. Encrypting header extensions is what RFC 9335’s “cryptex” does, and libwebrtc ships it disabled by default (crypto_options.h).

And it doesn’t mean what it’s often taken to mean. A readable header doesn’t let a media server forward Alice’s audio without her keys. An SFU, a server in the middle of a group call, still decrypts every packet with one leg’s keys and encrypts it again with the next (RFC 7667 §6), which post 6 counts out.

One call from cold start#

So how long does all of this take? Picture Alice opening the app for the first time today and saying “hey”. Before the assistant hears a sound, five things have to happen, each waiting for the one before:

  1. Gather and exchange. Her browser collects its candidates and sends the offer; the answer comes back (post 3, and the start of this one).
  2. Check. ICE sends its checks and waits for an answer: one round trip.
  3. Handshake. DTLS sends its four flights: two round trips. It can’t start before a check has worked, because until then there’s no path to send it on (RFC 8842 §6).
  4. First packet. Only now can her browser encrypt and send audio. It isn’t allowed to send any earlier: WebRTC forbids TLS False Start, a shortcut that would let data start flowing before the handshake has finished (RFC 8827 §6.5).
  5. First sound. The receiver’s jitter buffer (post 2) waits until it holds a few packets, then plays the first one.

The demo times exactly this between two aiortc peers, 90 times on one machine, and then again with a delay added to every packet to stand in for distance:

Three timelines on one millisecond axis, one call each, split into ICE, DTLS to both sides keyed, and media from first SRTP to first decoded frame. Loopback: checks answered 2.3 ms, ICE completed 22.4 and 26.5, DTLS 26.4 to 27.6, keyed 27.7 and 29.3, first SRTP 30.3, decoded 111.7; dashed boxes mark aioice's 20 ms tick and the jitter buffer's four-frame wait of about 80 ms. 25 ms each way: decoded at 253.2 ms. 50 ms each way: decoded at 406.2 ms. Below, medians of 30 calls: cold start 114.54, 255.45 and 404.47 ms; DTLS first flight to both keyed 4.22, 110.58 and 210.72 ms.
Every 50 ms of round trip adds about 140 to 150 ms before the first sound. One machine, a simulated delay, no packet loss, and no signalling server.
Distance between the two endsTime to the first soundWhere it went
None, same machine115 msAbout 82 ms is the jitter buffer waiting for four packets, and about 20 ms is the ICE library’s pacing. The protocols themselves took a few milliseconds
50 ms round trip255 msPlus one round trip for the checks and two for the handshake
100 ms round trip404 msThe same three round trips, twice as long

Medians of 30 calls each, aiortc 1.15.0 on one Apple M3 Pro.

Two things are worth taking from that. On a fast path, a call’s start-up time is mostly waiting, not cryptography: the handshake’s maths takes milliseconds. On a real path, it’s round trips, about three of them before the first sound, on top of however long the signalling takes. That’s why it matters where the server is: halve the round trip to it and each of those three gets twice as short.

And two tricks claw some of it back. Trickle ICE (post 3) lets the offer go out before gathering is done. And the answer’s setup:active saves a wait. The assistant’s server sends its answer over the signalling channel and, as soon as a check works, its first handshake message over the media path, at the same time. If the roles were the other way round, Alice would have to receive the answer before she could start the handshake, and the two would happen one after the other.

Watch your own browser do it#

The lab below sets up a call between two connections in this tab, and asks your browser what it negotiated: the DTLS version, the cipher, the SRTP profile, and which side was the DTLS client. Those are the values I couldn’t pin down for Chrome’s shipping build from its documentation, so here you can read them off your own. It also gives you both SDPs to paste into the explainer.

Keys, negotiated by your own browserlive lab

This lab runs a real call in your browser, so it needs JavaScript.

Look atWhat it shows
tlsVersionFour hex digits, the version’s two bytes: FEFD is DTLS 1.2, FEFC is DTLS 1.3
srtpCipherThe SRTP profile, by its registry name: the 10-byte-tag profile or a GCM one
dtlsRoleclient on the side that sent the ClientHello, which should be the answerer
The two SDPsNo key in either. A fingerprint each, actpass in the offer, active in the answer

chrome://webrtc-internals shows the same transport stats for any call.

The demo#

All the numbers above come from four Python scripts in keys-then-audio/, so you can check them and change them:

ScriptWhat it doesWhat to look for
srtp_one_packet.pyEncrypts post 1’s packet with a fixed, fake key, rebuilds the same bytes from the RFC alone, then sends the receiver the seven test packetsThe 12-byte header unchanged, the 10-byte tag, and which test packets are refused and why
keys.pyRuns a real handshake, prints the 60 exported bytes cut into keys and salts, and decrypts Alice’s first real packet with her halfThat both sides computed the same 60 bytes, and that the other half fails
sdp_fixtures.pyWrites the SDP samples the explainer uses, has aiortc parse them, and checks each against the RFCs’ rulesEvery sample passing, and the broken offer failing exactly the rules it was built to break
call_timeline.pySets up 30 calls from cold start and times every step; --delay 25 adds 25 ms each wayThe same five steps in the same order every time, and where the milliseconds go
Shell
Terminal window
cd mainlog-code/keys-then-audio
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python srtp_one_packet.py
.venv/bin/python keys.py
.venv/bin/python sdp_fixtures.py
.venv/bin/python call_timeline.py --delay 25

Its CI checks the encrypted bytes and the SDP files exactly, and the call timings only for their order and for a floor under the added delay, since timings depend on the machine.

One frame's pipeline. Top row, Alice's side: microphone, 960 samples (1,920 B, 20 ms); Opus encode, 80 B; RTP, 92 B; SRTP protect, 102 B; UDP and IPv4, 130 B; her router, 192.168.1.42:50004 to 192.0.2.10:54321; the path ICE chose. Bottom row, the assistant's side: the server at 198.51.100.20:50004, SRTP unprotect, jitter buffer, Opus decode, 960 samples again. Each box is tagged with the post that explained it: 1 for the sizes and the codec, 2 for the jitter buffer, 3 for the NAT and the path, 4 for SRTP.
Twenty milliseconds of Alice’s voice, and the four posts it took to follow it.

So before the first 20 ms of Alice’s voice can play, her browser has found a path, agreed on keys over it with a machine it has never met, and wrapped her packet so that every router can deliver it and none can read it. What none of this does is tell Alice’s laptop whether the packets are getting there. The next post is the receiver talking back.

Share this article

Related posts

3 items
  • Aug 21, 2026 8 min read Networking & media

    Half the bitrate, two thirds the traffic

    Halving an audio bitrate from 32 to 16 kb/s took traffic from 48 to 32, because every packet's headers stay the same size. Packet rate is the other control.

    Down to the wireaudionetworkingperformance
  • Oct 23, 2026 21 min read AI

    SFU, MCU, and the participant that has to decode

    Put an AI agent in a group call and two questions decide the design: does the model hear each person, or one blend, and who has to hold the keys? Both come down to where the agent's audio gets decoded.

    AI & agentic architecturesai-agentswebrtcarchitecture
  • Oct 7, 2026 28 min read Systems

    What a service mesh costs

    Your shop now runs 14 services in three languages, and security wants every call between them encrypted and identified. A mesh does that without touching the code, for about a fifth of a millisecond per hop and a tenth of a core per proxy per 1,000 requests a second, measured here with real Envoy. It also brings retry and queue defaults nobody chose. Over plain HTTP they retry less than most people expect; over gRPC, or after one common edit, a failing service gets 27 calls for one checkout.

    Down to the wireinfrastructuremicroservicesnetworking
  • Networking & media

    Half the bitrate, two thirds the traffic

    Halving an audio bitrate from 32 to 16 kb/s took traffic from 48 to 32, because every packet's headers stay the same size. Packet rate is the other control.

    audionetworking
    Aug 2026
  • AI

    SFU, MCU, and the participant that has to decode

    Put an AI agent in a group call and two questions decide the design: does the model hear each person, or one blend, and who has to hold the keys? Both come down to where the agent's audio gets decoded.

    ai-agentswebrtc
    Oct 2026
  • Systems

    What a service mesh costs

    Your shop now runs 14 services in three languages, and security wants every call between them encrypted and identified. A mesh does that without touching the code, for about a fifth of a millisecond per hop and a tenth of a core per proxy per 1,000 requests a second, measured here with real Envoy. It also brings retry and queue defaults nobody chose. Over plain HTTP they retry less than most people expect; over gRPC, or after one common edit, a failing service gets 27 calls for one checkout.

    infrastructuremicroservices
    Oct 2026

Comments