sipx_sdp/crypto.rs
1//! SDES: keying SRTP through SDP (RFC 4568).
2//!
3//! The mechanism is blunt. `a=crypto` carries the master key **in the SDP body**, base64-encoded
4//! and otherwise in the clear, and whoever can read the signalling can decrypt the media. RFC
5//! 4568 §7.1 is explicit that it therefore requires a secure signalling path, and treats that as
6//! a condition of use rather than as advice.
7//!
8//! sipx enforces it rather than documenting it. [`Crypto::offer`] takes a flag saying whether the
9//! signalling is secure and returns nothing when it is not, so an offer over cleartext SIP cannot
10//! carry a key by forgetting a check somewhere. That is the difference between a stack that has
11//! a rule and one that has a comment.
12//!
13//! What SDES cannot do is protect a key from an intermediary that terminates the TLS — a proxy,
14//! a session border controller. For that the keying has to happen on the media path, which is
15//! what DTLS-SRTP (RFC 5764) is for.
16
17use std::fmt;
18
19/// The crypto suite. Only the default SRTP transform is offered.
20///
21/// Deliberately not an open enum of every suite in the registry. sipx implements one transform;
22/// listing suites it cannot perform would produce an offer it could not honour, which is worse
23/// than a short list.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Suite {
26 /// AES-128 counter mode with an 80-bit HMAC-SHA1 tag.
27 AesCm128HmacSha1_80,
28}
29
30impl Suite {
31 /// The token as it appears in SDP.
32 #[must_use]
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::AesCm128HmacSha1_80 => "AES_CM_128_HMAC_SHA1_80",
36 }
37 }
38
39 /// The suite a token names, if it is one sipx can perform.
40 #[must_use]
41 pub fn parse(token: &str) -> Option<Self> {
42 // Case-sensitive: RFC 4568 §9.2 defines these as tokens with fixed spelling, and a peer
43 // that sends a different case is not offering this suite.
44 (token == Self::AesCm128HmacSha1_80.as_str()).then_some(Self::AesCm128HmacSha1_80)
45 }
46
47 /// How many octets of master key and master salt it uses.
48 #[must_use]
49 pub fn key_and_salt_len(self) -> (usize, usize) {
50 match self {
51 Self::AesCm128HmacSha1_80 => (16, 14),
52 }
53 }
54}
55
56/// One `a=crypto` line.
57#[derive(Clone, PartialEq, Eq)]
58pub struct Crypto {
59 /// The tag that identifies this offer among several.
60 pub tag: u32,
61 /// The transform.
62 pub suite: Suite,
63 /// Master key followed by master salt, concatenated as RFC 4568 §6.1 requires.
64 pub key_and_salt: Vec<u8>,
65}
66
67impl fmt::Debug for Crypto {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 // The key. A derived `Debug` would put it in whatever log the caller writes, which for a
70 // key carried in signalling is the likeliest way it escapes.
71 f.debug_struct("Crypto")
72 .field("tag", &self.tag)
73 .field("suite", &self.suite)
74 .finish_non_exhaustive()
75 }
76}
77
78impl Crypto {
79 /// A fresh offer, **only over a secure signalling path**.
80 ///
81 /// `None` when the signalling is not secure, and that is the point of the signature: RFC
82 /// 4568 §7.1 makes a secure path a condition of use, and a function that returned a key
83 /// regardless would leave every caller one forgotten check away from publishing it.
84 #[must_use]
85 pub fn offer(tag: u32, suite: Suite, secure_signalling: bool) -> Option<Self> {
86 if !secure_signalling {
87 return None;
88 }
89 let (key_len, salt_len) = suite.key_and_salt_len();
90 let mut key_and_salt = vec![0u8; key_len + salt_len];
91 fill_random(&mut key_and_salt);
92 Some(Self {
93 tag,
94 suite,
95 key_and_salt,
96 })
97 }
98
99 /// The master key half.
100 #[must_use]
101 pub fn master_key(&self) -> &[u8] {
102 let (key_len, _) = self.suite.key_and_salt_len();
103 self.key_and_salt.get(..key_len).unwrap_or(&[])
104 }
105
106 /// The master salt half.
107 #[must_use]
108 pub fn master_salt(&self) -> &[u8] {
109 let (key_len, _) = self.suite.key_and_salt_len();
110 self.key_and_salt.get(key_len..).unwrap_or(&[])
111 }
112
113 /// Read an `a=crypto` value: `<tag> <suite> inline:<base64>[|lifetime][|mki]`.
114 ///
115 /// `None` for anything sipx cannot act on — an unknown suite, a key of the wrong length, a
116 /// key parameter that is not `inline:`. Returning a half-understood offer would mean
117 /// answering with a suite that cannot be performed.
118 #[must_use]
119 pub fn parse(value: &str) -> Option<Self> {
120 let mut parts = value.split_whitespace();
121 let tag: u32 = parts.next()?.parse().ok()?;
122 let suite = Suite::parse(parts.next()?)?;
123
124 // Several key parameters may be offered; sipx takes the first `inline:` one it can use.
125 for parameter in parts {
126 let Some(rest) = parameter.strip_prefix("inline:") else {
127 continue;
128 };
129 // Lifetime and MKI follow the key, separated by `|`. sipx does not rekey, so the
130 // lifetime is not acted on — but a key with one still has to be readable.
131 let encoded = rest.split('|').next()?;
132 let key_and_salt = base64_decode(encoded)?;
133 let (key_len, salt_len) = suite.key_and_salt_len();
134 if key_and_salt.len() != key_len + salt_len {
135 return None;
136 }
137 return Some(Self {
138 tag,
139 suite,
140 key_and_salt,
141 });
142 }
143 None
144 }
145
146 /// This side's key, presented as the **accepted** attribute in an answer (RFC 4568 §5.1.2).
147 ///
148 /// The tag and the crypto-suite are the *offer's* — §5.1.2 requires the accepted attribute
149 /// in the answer to "contain … the tag and crypto-suite from the accepted crypto attribute
150 /// in the offer" — and the key is this side's own, because each direction is keyed
151 /// separately (RFC 3711 §3.2).
152 ///
153 /// Answering with a tag of this side's choosing is not a cosmetic difference. A conformant
154 /// offerer performs §5.1.3's check on the way back and MUST fail the negotiation when the
155 /// tag it sent is not the tag it gets, so an endpoint that always answers `1` interoperates
156 /// only with peers that happen to have offered `1`, and fails with no diagnosis at the end
157 /// that is wrong.
158 ///
159 /// `None` when this side's key cannot be presented under the offered suite — a key of the
160 /// wrong length for the suite named would be a well-formed answer nobody can decrypt.
161 #[must_use]
162 pub fn accepting(&self, offered: &Self) -> Option<Self> {
163 let (key_len, salt_len) = offered.suite.key_and_salt_len();
164 if self.key_and_salt.len() != key_len + salt_len {
165 return None;
166 }
167 Some(Self {
168 tag: offered.tag,
169 suite: offered.suite,
170 key_and_salt: self.key_and_salt.clone(),
171 })
172 }
173
174 /// Check an answer against what was offered, and return the offered attribute it accepted
175 /// (RFC 4568 §5.1.3).
176 ///
177 /// §5.1.3 is a MUST with three parts: the offerer verifies that one of the crypto suites it
178 /// offered **and its accompanying tag** were echoed, and that the answer carries a key. "If
179 /// any of the above fails, the negotiation MUST fail."
180 ///
181 /// `answered` is `None` when the answer carried no `a=crypto` this side can act on — which
182 /// is how an answer naming a suite that was never offered arrives, since [`Crypto::parse`]
183 /// refuses a suite sipx cannot perform. That is a failed negotiation and not a call in the
184 /// clear: a media path that quietly drops to no encryption because the answer disagreed is
185 /// worse than one that fails, because nothing tells anybody.
186 ///
187 /// What comes back is the *offered* attribute the answer accepted, so a caller keys with the
188 /// half it actually sent rather than with whichever of its offers came first.
189 ///
190 /// # Errors
191 ///
192 /// [`crate::SdpError::Invalid`] naming the tag, and never the key material: an error string
193 /// is a log line waiting to happen.
194 pub fn verify_answer<'o>(
195 offered: &'o [Self],
196 answered: Option<&Self>,
197 ) -> crate::Result<&'o Self> {
198 let Some(answered) = answered else {
199 return Err(crate::SdpError::Invalid {
200 field: "crypto",
201 value: "the answer carried no crypto attribute this side can perform".to_owned(),
202 });
203 };
204 // Tag *and* suite together. §5.1.3 asks for both, and matching on the tag alone would
205 // accept an answer that renamed the transform under a number this side did recognise.
206 let accepted = offered
207 .iter()
208 .find(|ours| ours.tag == answered.tag && ours.suite == answered.suite)
209 .ok_or_else(|| crate::SdpError::Invalid {
210 field: "crypto",
211 value: format!(
212 "the answer accepted tag {} ({}), which this side did not offer",
213 answered.tag,
214 answered.suite.as_str()
215 ),
216 })?;
217 // "and that the answer contains a key". Half a keying is a stream that connects and
218 // carries silence, which is the one outcome worse than a call that fails to connect.
219 let (key_len, salt_len) = answered.suite.key_and_salt_len();
220 if answered.key_and_salt.len() != key_len + salt_len {
221 return Err(crate::SdpError::Invalid {
222 field: "crypto",
223 value: format!("the answer to tag {} carried no usable key", answered.tag),
224 });
225 }
226 Ok(accepted)
227 }
228
229 /// Render as an `a=crypto` value.
230 #[must_use]
231 pub fn to_value(&self) -> String {
232 format!(
233 "{} {} inline:{}",
234 self.tag,
235 self.suite.as_str(),
236 base64_encode(&self.key_and_salt)
237 )
238 }
239}
240
241/// Fill a buffer with cryptographically random bytes.
242fn fill_random(out: &mut [u8]) {
243 use rand::RngCore;
244 rand::rng().fill_bytes(out);
245}
246
247const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
248
249/// Base64, as RFC 4568 §6.1 requires for the inline parameter.
250///
251/// Hand-written rather than pulled in as a dependency: it is twenty lines, and the alternative
252/// is another crate in the tree of a stack that carries audio.
253fn base64_encode(data: &[u8]) -> String {
254 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
255 for chunk in data.chunks(3) {
256 let byte = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0));
257 let group = (byte(0) << 16) | (byte(1) << 8) | byte(2);
258 for i in 0..4 {
259 if i <= chunk.len() {
260 let index = ((group >> (18 - i * 6)) & 0x3F) as usize;
261 // `index` is six bits and the alphabet is 64 long, so this cannot miss — but
262 // written as a lookup rather than an index so that stays true if either changes.
263 out.push(char::from(ALPHABET.get(index).copied().unwrap_or(b'A')));
264 } else {
265 out.push('=');
266 }
267 }
268 }
269 out
270}
271
272fn base64_decode(text: &str) -> Option<Vec<u8>> {
273 let mut bits = 0u32;
274 let mut held = 0u32;
275 let mut out = Vec::with_capacity(text.len() * 3 / 4);
276
277 for byte in text.bytes() {
278 if byte == b'=' {
279 break;
280 }
281 let value = ALPHABET.iter().position(|c| *c == byte)?;
282 bits = (bits << 6) | u32::try_from(value).ok()?;
283 held += 6;
284 if held >= 8 {
285 held -= 8;
286 out.push(u8::try_from((bits >> held) & 0xFF).ok()?);
287 }
288 }
289 Some(out)
290}
291
292#[cfg(test)]
293#[allow(
294 clippy::unwrap_used,
295 clippy::expect_used,
296 clippy::panic,
297 clippy::indexing_slicing
298)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn an_offer_round_trips() {
304 let offer = Crypto::offer(1, Suite::AesCm128HmacSha1_80, true).expect("secure");
305 let parsed = Crypto::parse(&offer.to_value()).expect("parses");
306 assert_eq!(parsed, offer);
307 assert_eq!(parsed.master_key().len(), 16);
308 assert_eq!(parsed.master_salt().len(), 14);
309 }
310
311 /// The rule RFC 4568 §7.1 states and most implementations only document. A key in an SDP
312 /// body is readable by anyone who can read the signalling, so an offer over cleartext SIP
313 /// publishes it.
314 #[test]
315 fn no_key_is_offered_over_cleartext_signalling() {
316 assert!(Crypto::offer(1, Suite::AesCm128HmacSha1_80, false).is_none());
317 assert!(Crypto::offer(1, Suite::AesCm128HmacSha1_80, true).is_some());
318 }
319
320 /// Two offers must not share a key. A generator seeded once, or reused, gives every call on
321 /// a host the same key — which authenticates and encrypts perfectly and protects nothing.
322 #[test]
323 fn every_offer_has_its_own_key() {
324 let one = Crypto::offer(1, Suite::AesCm128HmacSha1_80, true).expect("secure");
325 let two = Crypto::offer(1, Suite::AesCm128HmacSha1_80, true).expect("secure");
326 assert_ne!(one.key_and_salt, two.key_and_salt);
327 assert!(one.key_and_salt.iter().any(|b| *b != 0), "not all zeroes");
328 }
329
330 #[test]
331 fn base64_matches_the_published_vectors() {
332 // RFC 4648 §10.
333 for (plain, encoded) in [
334 ("", ""),
335 ("f", "Zg=="),
336 ("fo", "Zm8="),
337 ("foo", "Zm9v"),
338 ("foob", "Zm9vYg=="),
339 ("fooba", "Zm9vYmE="),
340 ("foobar", "Zm9vYmFy"),
341 ] {
342 assert_eq!(
343 base64_encode(plain.as_bytes()),
344 encoded,
345 "encoding {plain:?}"
346 );
347 assert_eq!(
348 base64_decode(encoded).expect("decodes"),
349 plain.as_bytes(),
350 "decoding {encoded:?}"
351 );
352 }
353 }
354
355 #[test]
356 fn a_lifetime_and_mki_do_not_stop_the_key_being_read() {
357 let offer = Crypto::offer(3, Suite::AesCm128HmacSha1_80, true).expect("secure");
358 let with_extras = format!("{}|2^20|1:4", offer.to_value());
359 let parsed = Crypto::parse(&with_extras).expect("parses");
360 assert_eq!(parsed.key_and_salt, offer.key_and_salt);
361 assert_eq!(parsed.tag, 3);
362 }
363
364 /// Anything sipx cannot act on is refused rather than half-understood. Answering with a
365 /// suite that cannot be performed is worse than not answering.
366 #[test]
367 fn an_offer_that_cannot_be_performed_is_refused() {
368 assert!(
369 Crypto::parse("1 AES_256_CM_HMAC_SHA1_80 inline:AAAA").is_none(),
370 "unknown suite"
371 );
372 assert!(
373 Crypto::parse("1 AES_CM_128_HMAC_SHA1_80 inline:AAAA").is_none(),
374 "short key"
375 );
376 assert!(
377 Crypto::parse("1 AES_CM_128_HMAC_SHA1_80").is_none(),
378 "no key parameter"
379 );
380 assert!(
381 Crypto::parse("x AES_CM_128_HMAC_SHA1_80 inline:AAAA").is_none(),
382 "bad tag"
383 );
384 assert!(Crypto::parse("").is_none());
385 // A key parameter that is not `inline:` — a key management protocol sipx does not speak.
386 assert!(
387 Crypto::parse("1 AES_CM_128_HMAC_SHA1_80 keymgmt:mikey AQAA").is_none(),
388 "a keying method sipx cannot perform"
389 );
390 }
391
392 /// **The published line, not one of ours.** `docs/specs/srtp.md` §10.4 restates RFC 4568
393 /// §6.1's `a=crypto` example and what its `inline` parameter decodes to; this asserts
394 /// `Crypto::parse` against those octets.
395 ///
396 /// Every other test in this module feeds the parser something [`Crypto::offer`] produced, so
397 /// a parser that is self-consistently wrong reads as correct — which is exactly how
398 /// `sipx-rtp` keyed HMAC with the wrong constant through six releases (§12.1).
399 #[test]
400 fn the_published_crypto_line_parses_to_the_published_key_and_salt() {
401 let published = "1 AES_CM_128_HMAC_SHA1_80 \
402 inline:d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj|2^20|1:4";
403 let parsed = Crypto::parse(published).expect("RFC 4568 §6.1's own example");
404
405 assert_eq!(parsed.tag, 1);
406 assert_eq!(parsed.suite, Suite::AesCm128HmacSha1_80);
407 assert_eq!(
408 parsed.master_key(),
409 [
410 0x77, 0x44, 0x66, 0x76, 0x67, 0x26, 0x54, 0x2B, 0x29, 0x78, 0x47, 0x37, 0x40, 0x66,
411 0x62, 0x35
412 ],
413 "the 16 master key octets §10.4 publishes"
414 );
415 assert_eq!(
416 parsed.master_salt(),
417 [
418 0x6A, 0x55, 0x2C, 0x52, 0x61, 0x41, 0x7D, 0x5C, 0x7C, 0x70, 0x30, 0x25, 0x2A, 0x23
419 ],
420 "the 14 master salt octets §10.4 publishes"
421 );
422 }
423
424 /// The other two published `inline` parameters, from RFC 4568 §4 and §6.1. Both are 30
425 /// octets and both are legal input — including the one whose lifetime is written in the
426 /// decimal form rather than as a power of two.
427 #[test]
428 fn the_other_published_inline_parameters_are_read() {
429 for value in [
430 "1 AES_CM_128_HMAC_SHA1_80 inline:PS1uQCVeeCFCanVmcjkpPywjNWhcYD0mXXtxaVBR",
431 "1 AES_CM_128_HMAC_SHA1_80 inline:YUJDZGVmZ2hpSktMbW9QUXJzVHVWd3l6MTIzNDU2|1066:4",
432 ] {
433 let parsed = Crypto::parse(value).unwrap_or_else(|| panic!("published: {value}"));
434 assert_eq!(parsed.master_key().len(), 16, "{value}");
435 assert_eq!(parsed.master_salt().len(), 14, "{value}");
436 }
437 }
438
439 #[test]
440 fn the_suite_token_is_case_sensitive() {
441 assert!(Suite::parse("AES_CM_128_HMAC_SHA1_80").is_some());
442 assert!(Suite::parse("aes_cm_128_hmac_sha1_80").is_none());
443 }
444
445 /// The key must not reach a log through a derived `Debug`, which for a key carried in
446 /// signalling is the likeliest way it escapes.
447 #[test]
448 fn debug_output_does_not_leak_the_key() {
449 let offer = Crypto::offer(1, Suite::AesCm128HmacSha1_80, true).expect("secure");
450 let printed = format!("{offer:?}");
451 assert!(printed.contains("tag: 1"), "{printed}");
452 assert!(!printed.contains("key_and_salt"), "{printed}");
453 let encoded = base64_encode(&offer.key_and_salt);
454 assert!(!printed.contains(&encoded[..8]), "{printed}");
455 }
456}