Skip to main content

mail_auth/
mta_sts.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7//! Parsers for the MTA-STS and SMTP TLS Reporting DNS TXT records.
8//!
9//! - [`MtaStsRecord`]: the `_mta-sts` TXT record of RFC 8461 Section 3.1,
10//!   which announces that a domain publishes an MTA-STS policy.
11//! - [`TlsRptRecord`]: the `_smtp._tls` TXT record of RFC 8460 Section 3,
12//!   which lists where TLS failure reports are sent.
13//!
14//! Both are parsed with [`TxtRecordParser::parse`] and fetched through
15//! [`MessageAuthenticator::txt_lookup`](crate::MessageAuthenticator::txt_lookup).
16//! Fetching the MTA-STS policy file over HTTPS is out of scope.
17
18use crate::{
19    DnsError,
20    parse::{TagParser, TxtRecordParser, V},
21};
22use serde::{Deserialize, Serialize};
23
24/// An MTA-STS TXT record (RFC 8461 Section 3.1), published at
25/// `_mta-sts.<domain>`.
26///
27/// Parsing requires a `v=STSv1` tag and an `id` tag; other tags are ignored.
28#[derive(Debug, PartialEq, Eq)]
29pub struct MtaStsRecord {
30    /// Policy identifier (`id=` tag): an opaque string of up to 32
31    /// alphanumeric characters that changes whenever the policy changes.
32    pub id: String,
33}
34
35#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
36/// An SMTP TLS Reporting TXT record (RFC 8460 Section 3), published at
37/// `_smtp._tls.<domain>`.
38///
39/// Parsing requires the `v=TLSRPTv1` tag first and at least one `mailto:` or
40/// `https:` URI in `rua`.
41pub struct TlsRptRecord {
42    /// Aggregate report destinations (`rua=` tag), in record order. URIs with
43    /// other schemes are ignored.
44    pub rua: Vec<ReportUri>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48/// A TLS report destination from the `rua` tag of a [`TlsRptRecord`].
49pub enum ReportUri {
50    /// A `mailto:` destination: the email address, without the scheme.
51    Mail(String),
52    /// An `https:` destination: the full URL, including the scheme.
53    Http(String),
54}
55
56const ID: u64 = (b'i' as u64) | ((b'd' as u64) << 8);
57const RUA: u64 = (b'r' as u64) | ((b'u' as u64) << 8) | ((b'a' as u64) << 16);
58
59const MAILTO: u64 = (b'm' as u64)
60    | ((b'a' as u64) << 8)
61    | ((b'i' as u64) << 16)
62    | ((b'l' as u64) << 24)
63    | ((b't' as u64) << 32)
64    | ((b'o' as u64) << 40);
65const HTTPS: u64 = (b'h' as u64)
66    | ((b't' as u64) << 8)
67    | ((b't' as u64) << 16)
68    | ((b'p' as u64) << 24)
69    | ((b's' as u64) << 32);
70
71impl TxtRecordParser for MtaStsRecord {
72    #[allow(clippy::while_let_on_iterator)]
73    fn parse(record: &[u8]) -> crate::Result<Self> {
74        let mut record = record.iter();
75        let mut id = None;
76        let mut has_version = false;
77
78        while let Some(key) = record.key() {
79            match key {
80                V => {
81                    if !record.match_bytes(b"STSv1") || !record.seek_tag_end() {
82                        return Err(crate::Error::Dns(DnsError::InvalidRecordType));
83                    }
84                    has_version = true;
85                }
86                ID => {
87                    id = record.text(false).into();
88                }
89                _ => {
90                    record.ignore();
91                }
92            }
93        }
94
95        if let Some(id) = id
96            && has_version
97        {
98            return Ok(MtaStsRecord { id });
99        }
100        Err(crate::Error::Dns(DnsError::InvalidRecordType))
101    }
102}
103
104impl TxtRecordParser for TlsRptRecord {
105    #[allow(clippy::while_let_on_iterator)]
106    fn parse(record: &[u8]) -> crate::Result<Self> {
107        let mut record = record.iter();
108
109        if record.key().unwrap_or(0) != V
110            || !record.match_bytes(b"TLSRPTv1")
111            || !record.seek_tag_end()
112        {
113            return Err(crate::Error::Dns(DnsError::InvalidRecordType));
114        }
115
116        let mut rua = Vec::new();
117
118        while let Some(key) = record.key() {
119            match key {
120                RUA => loop {
121                    match record.flag_value() {
122                        (MAILTO, b':') => {
123                            let mail_to = record.uri(Vec::new());
124                            if !mail_to.is_empty() {
125                                rua.push(ReportUri::Mail(mail_to));
126                            }
127                        }
128                        (HTTPS, b':') => {
129                            let mut url = Vec::with_capacity(20);
130                            url.extend_from_slice(b"https:");
131                            let url = record.uri(url);
132                            if !url.is_empty() {
133                                rua.push(ReportUri::Http(url));
134                            }
135                        }
136                        _ => {
137                            record.ignore();
138                            break;
139                        }
140                    }
141                },
142                _ => {
143                    record.ignore();
144                }
145            }
146        }
147
148        if !rua.is_empty() {
149            Ok(TlsRptRecord { rua })
150        } else {
151            Err(crate::Error::Dns(DnsError::InvalidRecordType))
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use crate::{
159        mta_sts::{MtaStsRecord, ReportUri, TlsRptRecord},
160        parse::TxtRecordParser,
161    };
162
163    #[test]
164    fn mta_sts_record_parse() {
165        for (mta_sts, expected_mta_sts) in [
166            (
167                "v=STSv1; id=20160831085700Z;",
168                MtaStsRecord {
169                    id: "20160831085700Z".to_string(),
170                },
171            ),
172            (
173                "v=STSv1; id=20190429T010101",
174                MtaStsRecord {
175                    id: "20190429T010101".to_string(),
176                },
177            ),
178        ] {
179            assert_eq!(
180                MtaStsRecord::parse(mta_sts.as_bytes()).unwrap(),
181                expected_mta_sts
182            );
183        }
184    }
185
186    #[test]
187    fn tlsrpt_parse() {
188        for (tls_rpt, expected_tls_rpt) in [
189            (
190                "v=TLSRPTv1;rua=mailto:reports@example.com",
191                TlsRptRecord {
192                    rua: vec![ReportUri::Mail("reports@example.com".to_string())],
193                },
194            ),
195            (
196                "v=TLSRPTv1; rua=https://reporting.example.com/v1/tlsrpt",
197                TlsRptRecord {
198                    rua: vec![ReportUri::Http(
199                        "https://reporting.example.com/v1/tlsrpt".to_string(),
200                    )],
201                },
202            ),
203            (
204                "v=TLSRPTv1; rua=mailto:tlsrpt@mydomain.com,https://tlsrpt.mydomain.com/v1",
205                TlsRptRecord {
206                    rua: vec![
207                        ReportUri::Mail("tlsrpt@mydomain.com".to_string()),
208                        ReportUri::Http("https://tlsrpt.mydomain.com/v1".to_string()),
209                    ],
210                },
211            ),
212            (
213                "v=TLSRPTv1; rua=https://reporting.example.com/v1/tlsrpt?id=1",
214                TlsRptRecord {
215                    rua: vec![ReportUri::Http(
216                        "https://reporting.example.com/v1/tlsrpt?id=1".to_string(),
217                    )],
218                },
219            ),
220            (
221                "v=TLSRPTv1; rua=https://r.example.com/tlsrpt?id=abc&token=xyz",
222                TlsRptRecord {
223                    rua: vec![ReportUri::Http(
224                        "https://r.example.com/tlsrpt?id=abc&token=xyz".to_string(),
225                    )],
226                },
227            ),
228            (
229                "v=TLSRPTv1; rua=mailto:a@example.com , mailto:b=c@example.com ;",
230                TlsRptRecord {
231                    rua: vec![
232                        ReportUri::Mail("a@example.com".to_string()),
233                        ReportUri::Mail("b=c@example.com".to_string()),
234                    ],
235                },
236            ),
237        ] {
238            assert_eq!(
239                TlsRptRecord::parse(tls_rpt.as_bytes()).unwrap(),
240                expected_tls_rpt
241            );
242        }
243    }
244}