Skip to main content

sipx_sdp/
fingerprint.rs

1//! Certificate fingerprints and the TLS role, in SDP (RFC 8122, RFC 4145).
2//!
3//! This is the half of DTLS-SRTP that travels in the signalling. The key never does — that is the
4//! entire point, and the difference from SDES ([`crate::crypto`], RFC 4568), where the key *is* the
5//! SDP. Here the SDP carries only a **hash of the certificate** that will appear on the media path,
6//! so a proxy or session border controller that terminates the TLS learns nothing it can decrypt
7//! with. What it can do is substitute a fingerprint of its own; RFC 8122 §7 is clear that the
8//! mechanism's guarantee is therefore only as good as the integrity of the signalling.
9//!
10//! The check is not optional. RFC 8122 §6.2: an endpoint whose peer's certificate "does not match
11//! the original fingerprint" MUST "terminate the media connection with a `bad_certificate` error". A
12//! stack that sends a fingerprint and does not verify one has implemented the decoration and not
13//! the mechanism.
14
15use std::fmt;
16
17/// The hash a fingerprint was taken with (RFC 8122 §5).
18///
19/// MD2 and MD5 are deliberately absent. §5: implementations "MUST NOT use the MD2 and MD5 hash
20/// functions to calculate fingerprints or to verify received fingerprints that have been calculated
21/// using them". A parser that accepted them would be offering a caller a value it is forbidden to
22/// act on, so they are rejected at the door instead.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum HashFunc {
25    /// SHA-1. Accepted for interoperability with peers that still send it; not what sipx offers.
26    Sha1,
27    /// SHA-224.
28    Sha224,
29    /// SHA-256 — §5's preferred function, and what sipx sends.
30    Sha256,
31    /// SHA-384.
32    Sha384,
33    /// SHA-512.
34    Sha512,
35}
36
37impl HashFunc {
38    /// The token as it appears in SDP.
39    #[must_use]
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Sha1 => "sha-1",
43            Self::Sha224 => "sha-224",
44            Self::Sha256 => "sha-256",
45            Self::Sha384 => "sha-384",
46            Self::Sha512 => "sha-512",
47        }
48    }
49
50    /// The function a token names, if it is one that may be used.
51    ///
52    /// Case-insensitive: §5's grammar makes `hash-func` a token, and RFC 8866 §5 does not fix the
53    /// case of attribute values. `md5` and `md2` parse to `None` — the grammar allows them and §5
54    /// forbids acting on them, and returning `None` is how that prohibition is expressed here.
55    #[must_use]
56    pub fn parse(token: &str) -> Option<Self> {
57        [
58            Self::Sha1,
59            Self::Sha224,
60            Self::Sha256,
61            Self::Sha384,
62            Self::Sha512,
63        ]
64        .into_iter()
65        .find(|candidate| token.eq_ignore_ascii_case(candidate.as_str()))
66    }
67
68    /// How many octets the digest has.
69    #[must_use]
70    pub fn digest_len(self) -> usize {
71        match self {
72            Self::Sha1 => 20,
73            Self::Sha224 => 28,
74            Self::Sha256 => 32,
75            Self::Sha384 => 48,
76            Self::Sha512 => 64,
77        }
78    }
79
80    /// Hash a certificate with this function.
81    #[must_use]
82    pub fn hash(self, certificate: &[u8]) -> Vec<u8> {
83        use sha1::Sha1;
84        use sha2::{Digest as _, Sha224, Sha256, Sha384, Sha512};
85        match self {
86            Self::Sha1 => Sha1::digest(certificate).to_vec(),
87            Self::Sha224 => Sha224::digest(certificate).to_vec(),
88            Self::Sha256 => Sha256::digest(certificate).to_vec(),
89            Self::Sha384 => Sha384::digest(certificate).to_vec(),
90            Self::Sha512 => Sha512::digest(certificate).to_vec(),
91        }
92    }
93}
94
95/// One `a=fingerprint` value (RFC 8122 §5).
96#[derive(Clone, PartialEq, Eq)]
97pub struct Fingerprint {
98    /// Which hash it was taken with.
99    pub func: HashFunc,
100    /// The digest itself, as octets rather than as text — a fingerprint is compared to a hash of a
101    /// certificate, and keeping it as the printed form would mean re-parsing to do that.
102    pub digest: Vec<u8>,
103}
104
105impl fmt::Debug for Fingerprint {
106    /// The digest is not a secret, but printing 64 hex pairs in a log line is noise. The function
107    /// and the length are what a reader is looking for.
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("Fingerprint")
110            .field("func", &self.func)
111            .field("digest_len", &self.digest.len())
112            .finish()
113    }
114}
115
116impl Fingerprint {
117    /// The fingerprint of a DER-encoded certificate, taken with `func`.
118    #[must_use]
119    pub fn of(certificate: &[u8], func: HashFunc) -> Self {
120        Self {
121            func,
122            digest: func.hash(certificate),
123        }
124    }
125
126    /// Read an `a=fingerprint` value: `<hash-func> SP <2UHEX *(":" 2UHEX)>`.
127    ///
128    /// Returns `None` for a hash sipx may not act on, a malformed digest, or a digest whose length
129    /// does not match the function named. The last check matters: a truncated digest that compared
130    /// equal against the first bytes of a certificate hash would be a fingerprint check that
131    /// verifies almost nothing.
132    #[must_use]
133    pub fn parse(value: &str) -> Option<Self> {
134        let mut parts = value.trim().split_ascii_whitespace();
135        let func = HashFunc::parse(parts.next()?)?;
136        let printed = parts.next()?;
137        if parts.next().is_some() {
138            return None;
139        }
140        let mut digest = Vec::with_capacity(func.digest_len());
141        for pair in printed.split(':') {
142            if pair.len() != 2 {
143                return None;
144            }
145            digest.push(u8::from_str_radix(pair, 16).ok()?);
146        }
147        (digest.len() == func.digest_len()).then_some(Self { func, digest })
148    }
149
150    /// Render as an `a=fingerprint` value.
151    ///
152    /// Uppercase hex: §5's `UHEX` rule is `DIGIT / %x41-46`, which is uppercase only. Lowercase is
153    /// what most implementations accept anyway, and is still not what the grammar says.
154    #[must_use]
155    pub fn to_value(&self) -> String {
156        use std::fmt::Write as _;
157        let printed =
158            self.digest
159                .iter()
160                .enumerate()
161                .fold(String::new(), |mut out, (index, byte)| {
162                    if index > 0 {
163                        out.push(':');
164                    }
165                    let _ = write!(out, "{byte:02X}");
166                    out
167                });
168        format!("{} {printed}", self.func.as_str())
169    }
170
171    /// Whether a certificate is the one this fingerprint names (RFC 8122 §6.2).
172    ///
173    /// Compared in constant time. The value is public, so this is not about protecting the digest —
174    /// it is about not giving an attacker who can offer certificates a byte-at-a-time oracle for
175    /// how far a forged one matched.
176    #[must_use]
177    pub fn matches(&self, certificate: &[u8]) -> bool {
178        use subtle::ConstantTimeEq as _;
179        let computed = self.func.hash(certificate);
180        computed.ct_eq(&self.digest).into()
181    }
182}
183
184/// Who opens the DTLS connection (RFC 4145 §4, used by RFC 5763 §5).
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Setup {
187    /// This endpoint will start the handshake — the DTLS **client**.
188    Active,
189    /// This endpoint will wait for it — the DTLS **server**.
190    Passive,
191    /// Either; the answerer chooses. RFC 5763 §5 requires an *offerer* to use this.
192    ActPass,
193    /// No connection is to be formed, for an offer that is only describing a stream.
194    HoldConn,
195}
196
197/// DTLS roles the local handshake implementation can hold.
198///
199/// Kept in the SDP crate because offer/answer has to refuse an impossible role before a media
200/// worker starts. It describes capability only; sockets and handshakes remain in `sipx-media`.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct SetupCapabilities {
203    client: bool,
204    server: bool,
205}
206
207impl SetupCapabilities {
208    /// Both DTLS roles, which the default handshake implementation supports.
209    #[must_use]
210    pub const fn both() -> Self {
211        Self {
212            client: true,
213            server: true,
214        }
215    }
216
217    /// Only the active/client role.
218    #[must_use]
219    pub const fn client_only() -> Self {
220        Self {
221            client: true,
222            server: false,
223        }
224    }
225
226    /// Only the passive/server role.
227    #[must_use]
228    pub const fn server_only() -> Self {
229        Self {
230            client: false,
231            server: true,
232        }
233    }
234
235    /// No usable DTLS role. Useful for representing a feature or adapter that is unavailable.
236    #[must_use]
237    pub const fn neither() -> Self {
238        Self {
239            client: false,
240            server: false,
241        }
242    }
243
244    /// Select the answer to an offered setup value.
245    ///
246    /// `actpass` prefers active, as RFC 5763 §5 recommends, but a server-only implementation may
247    /// legally select passive. A fixed offer can be answered only with its complement.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`SetupRoleError`] when this endpoint cannot hold the required role.
252    pub fn answer_to(self, offered: Setup) -> Result<Setup, SetupRoleError> {
253        match offered {
254            Setup::ActPass | Setup::Passive if self.client => Ok(Setup::Active),
255            Setup::ActPass | Setup::Active if self.server => Ok(Setup::Passive),
256            Setup::ActPass => Err(SetupRoleError::NoAnswerRole),
257            Setup::Passive => Err(SetupRoleError::UnsupportedLocalRole(Setup::Active)),
258            Setup::Active => Err(SetupRoleError::UnsupportedLocalRole(Setup::Passive)),
259            // DTLS-SRTP needs a handshake. `holdconn` deliberately establishes none, so carrying
260            // it into the answer would fail only after the successful SIP response had left.
261            Setup::HoldConn => Err(SetupRoleError::UnresolvedOffer(Setup::HoldConn)),
262        }
263    }
264
265    /// Resolve this offerer's local role from the answer.
266    ///
267    /// # Errors
268    ///
269    /// Returns a typed refusal for a missing or unresolved answer and when the complementary role
270    /// is not available locally. No caller needs to start a handshake to discover this.
271    pub fn from_answer(self, answered: Option<Setup>) -> Result<Setup, SetupRoleError> {
272        match answered {
273            None => Err(SetupRoleError::MissingAnswer),
274            Some(Setup::Active) if self.server => Ok(Setup::Passive),
275            Some(Setup::Active) => Err(SetupRoleError::UnsupportedLocalRole(Setup::Passive)),
276            Some(Setup::Passive) if self.client => Ok(Setup::Active),
277            Some(Setup::Passive) => Err(SetupRoleError::UnsupportedLocalRole(Setup::Active)),
278            Some(unresolved @ (Setup::ActPass | Setup::HoldConn)) => {
279                Err(SetupRoleError::UnresolvedAnswer(unresolved))
280            }
281        }
282    }
283}
284
285impl Default for SetupCapabilities {
286    fn default() -> Self {
287        Self::both()
288    }
289}
290
291/// A DTLS setup exchange that cannot select a local handshake role.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
293#[non_exhaustive]
294pub enum SetupRoleError {
295    /// An offer selected no role with which a DTLS-SRTP handshake can be formed.
296    #[error("the DTLS offer did not select a usable setup role: {0:?}")]
297    UnresolvedOffer(Setup),
298    /// The answer omitted the role the offerer needs to act on.
299    #[error("the DTLS answer supplied no setup role")]
300    MissingAnswer,
301    /// An answer did not resolve the offer into one endpoint role.
302    #[error("the DTLS answer did not resolve its setup role: {0:?}")]
303    UnresolvedAnswer(Setup),
304    /// The peer's answer requires a role this local implementation cannot hold.
305    #[error("the DTLS answer requires an unsupported local setup role: {0:?}")]
306    UnsupportedLocalRole(Setup),
307    /// The answerer supports neither role available to an `actpass` offer.
308    #[error("no supported DTLS setup role is available for the answer")]
309    NoAnswerRole,
310}
311
312impl Setup {
313    /// The token as it appears in SDP.
314    #[must_use]
315    pub fn as_str(self) -> &'static str {
316        match self {
317            Self::Active => "active",
318            Self::Passive => "passive",
319            Self::ActPass => "actpass",
320            Self::HoldConn => "holdconn",
321        }
322    }
323
324    /// The role a token names.
325    #[must_use]
326    pub fn parse(token: &str) -> Option<Self> {
327        [Self::Active, Self::Passive, Self::ActPass, Self::HoldConn]
328            .into_iter()
329            .find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
330    }
331
332    /// The role to answer an offered one with (RFC 4145 §4.1).
333    ///
334    /// `actpass` is answered `active`, which is RFC 5763 §5's recommendation and not merely a
335    /// preference: the answerer starting the handshake means the *offerer* does not have to send
336    /// packets to an address it has only just learned, which is what gets a DTLS `ClientHello`
337    /// through a NAT the answerer sits behind.
338    ///
339    /// `holdconn` is answered `holdconn`: §4.1 gives no other legal answer, and answering with a
340    /// role would be agreeing to a connection the offerer said not to form.
341    #[must_use]
342    pub fn answer(offered: Self) -> Self {
343        match offered {
344            Self::ActPass | Self::Passive => Self::Active,
345            Self::Active => Self::Passive,
346            Self::HoldConn => Self::HoldConn,
347        }
348    }
349
350    /// Whether holding this role makes this endpoint the DTLS client (RFC 8122 §6.2).
351    #[must_use]
352    pub fn is_client(self) -> bool {
353        matches!(self, Self::Active)
354    }
355}
356
357#[cfg(test)]
358#[allow(
359    clippy::unwrap_used,
360    clippy::expect_used,
361    clippy::panic,
362    clippy::indexing_slicing
363)]
364mod tests {
365    use super::*;
366
367    /// A fingerprint round-trips through the printed form byte for byte.
368    #[test]
369    fn a_fingerprint_round_trips_through_its_sdp_form() {
370        let certificate = b"a certificate, for the purposes of hashing something";
371        let printed = Fingerprint::of(certificate, HashFunc::Sha256).to_value();
372        let parsed = Fingerprint::parse(&printed).expect("parses");
373        assert_eq!(parsed.func, HashFunc::Sha256);
374        assert!(parsed.matches(certificate));
375        assert_eq!(parsed.to_value(), printed);
376    }
377
378    /// RFC 8122 §5's `UHEX` rule is `DIGIT / %x41-46` — uppercase.
379    #[test]
380    fn the_printed_form_is_uppercase_hex_separated_by_colons() {
381        let fingerprint = Fingerprint {
382            func: HashFunc::Sha1,
383            digest: vec![
384                0xab, 0xcd, 0x01, 0x9f, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
385            ],
386        };
387        let value = fingerprint.to_value();
388        assert!(value.starts_with("sha-1 AB:CD:01:9F:"), "{value}");
389        assert_eq!(
390            value.matches(':').count(),
391            19,
392            "twenty octets means nineteen separators"
393        );
394    }
395
396    /// Lowercase is what most of the world sends, and rejecting it would be interoperability
397    /// theatre — the grammar's case rule binds a *generator*.
398    #[test]
399    fn a_lowercase_fingerprint_from_a_peer_is_still_read() {
400        let certificate = b"cert";
401        let upper = Fingerprint::of(certificate, HashFunc::Sha256).to_value();
402        let lower = upper.to_ascii_lowercase();
403        let parsed = Fingerprint::parse(&lower).expect("a peer's lowercase value parses");
404        assert!(parsed.matches(certificate));
405    }
406
407    /// §5: implementations "MUST NOT use the MD2 and MD5 hash functions to calculate fingerprints
408    /// or to verify received fingerprints". Refused at the parser, so no caller can act on one.
409    #[test]
410    fn md5_and_md2_fingerprints_are_refused_rather_than_carried() {
411        assert!(HashFunc::parse("md5").is_none());
412        assert!(HashFunc::parse("md2").is_none());
413        assert!(
414            Fingerprint::parse("md5 AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89").is_none(),
415            "a forbidden hash must not produce a fingerprint a caller could check against"
416        );
417    }
418
419    /// A digest of the wrong length for the function named is malformed, not merely short. A
420    /// truncated one that compared equal against a prefix would verify almost nothing.
421    #[test]
422    fn a_digest_of_the_wrong_length_for_its_hash_is_refused() {
423        assert!(
424            Fingerprint::parse("sha-256 AB:CD").is_none(),
425            "two octets is not a SHA-256 digest"
426        );
427        let sha1_digest = Fingerprint::of(b"cert", HashFunc::Sha1).to_value();
428        let mislabelled = sha1_digest.replace("sha-1", "sha-256");
429        assert!(
430            Fingerprint::parse(&mislabelled).is_none(),
431            "a 20-octet digest labelled sha-256 must not be accepted"
432        );
433    }
434
435    #[test]
436    fn a_malformed_fingerprint_is_refused() {
437        assert!(Fingerprint::parse("").is_none());
438        assert!(Fingerprint::parse("sha-256").is_none(), "no digest");
439        assert!(Fingerprint::parse("sha-256 ZZ:ZZ").is_none(), "not hex");
440        assert!(
441            Fingerprint::parse("sha-256 ABC:DE").is_none(),
442            "groups are two hex digits"
443        );
444        let good = Fingerprint::of(b"cert", HashFunc::Sha256).to_value();
445        assert!(
446            Fingerprint::parse(&format!("{good} extra")).is_none(),
447            "a trailing token is not part of the grammar"
448        );
449    }
450
451    /// The check RFC 8122 §6.2 makes mandatory: a certificate that is not the one named must not
452    /// match. This is the assertion the whole mechanism rests on.
453    #[test]
454    fn a_different_certificate_does_not_match() {
455        let fingerprint = Fingerprint::of(b"the real certificate", HashFunc::Sha256);
456        assert!(fingerprint.matches(b"the real certificate"));
457        assert!(!fingerprint.matches(b"a substituted certificate"));
458        // And a one-bit difference is still a difference.
459        assert!(!fingerprint.matches(b"the real certificatf"));
460    }
461
462    /// Every hash sipx accepts produces a digest of the length it declares — the length check in
463    /// `parse` is only as good as this table.
464    #[test]
465    fn every_hash_produces_the_digest_length_it_declares() {
466        for func in [
467            HashFunc::Sha1,
468            HashFunc::Sha224,
469            HashFunc::Sha256,
470            HashFunc::Sha384,
471            HashFunc::Sha512,
472        ] {
473            assert_eq!(
474                func.hash(b"cert").len(),
475                func.digest_len(),
476                "{}",
477                func.as_str()
478            );
479        }
480    }
481
482    /// RFC 4145 §4.1, and RFC 5763 §5's reason for preferring it.
483    #[test]
484    fn actpass_is_answered_active_so_the_answerer_starts_the_handshake() {
485        assert_eq!(Setup::answer(Setup::ActPass), Setup::Active);
486        assert!(
487            Setup::answer(Setup::ActPass).is_client(),
488            "the answerer becomes the DTLS client, so its `ClientHello` opens the NAT it is behind"
489        );
490        assert_eq!(Setup::answer(Setup::Passive), Setup::Active);
491        assert_eq!(Setup::answer(Setup::Active), Setup::Passive);
492    }
493
494    /// §4.1 gives no other legal answer to `holdconn`, and answering with a role would agree to a
495    /// connection the offerer said not to form.
496    #[test]
497    fn holdconn_is_answered_holdconn() {
498        assert_eq!(Setup::answer(Setup::HoldConn), Setup::HoldConn);
499        assert!(!Setup::answer(Setup::HoldConn).is_client());
500    }
501
502    #[test]
503    fn setup_round_trips_and_rejects_what_is_not_a_role() {
504        for role in [
505            Setup::Active,
506            Setup::Passive,
507            Setup::ActPass,
508            Setup::HoldConn,
509        ] {
510            assert_eq!(Setup::parse(role.as_str()), Some(role));
511            assert_eq!(
512                Setup::parse(&role.as_str().to_ascii_uppercase()),
513                Some(role)
514            );
515        }
516        assert!(Setup::parse("both").is_none());
517        assert!(Setup::parse("").is_none());
518    }
519
520    #[test]
521    fn only_active_is_the_client() {
522        assert!(Setup::Active.is_client());
523        assert!(!Setup::Passive.is_client());
524        // `actpass` is an offer, not a role. Treating it as a role is how both ends end up as
525        // clients and neither answers.
526        assert!(!Setup::ActPass.is_client());
527        assert!(!Setup::HoldConn.is_client());
528    }
529}