Skip to main content

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}