mail_auth/common/
auth_results.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::headers::{HeaderWriter, Writer};
8use crate::{
9    ArcOutput, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
10    IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult,
11};
12use mail_builder::encoders::base64::base64_encode;
13use std::{
14    borrow::Cow,
15    fmt::{Display, Write},
16    net::IpAddr,
17};
18
19impl<'x> AuthenticationResults<'x> {
20    pub fn new(hostname: &'x str) -> Self {
21        AuthenticationResults {
22            hostname,
23            auth_results: String::with_capacity(64),
24        }
25    }
26
27    pub fn with_dkim_results(mut self, dkim: &[DkimOutput], header_from: &str) -> Self {
28        for dkim in dkim {
29            self.set_dkim_result(dkim, header_from);
30        }
31        self
32    }
33
34    pub fn with_dkim_result(mut self, dkim: &DkimOutput, header_from: &str) -> Self {
35        self.set_dkim_result(dkim, header_from);
36        self
37    }
38
39    pub fn set_dkim_result(&mut self, dkim: &DkimOutput, header_from: &str) {
40        if !dkim.is_atps {
41            self.auth_results.push_str(";\r\n\tdkim=");
42        } else {
43            self.auth_results.push_str(";\r\n\tdkim-atps=");
44        }
45        dkim.result.as_auth_result(&mut self.auth_results);
46        if let Some(signature) = &dkim.signature {
47            if !signature.i.is_empty() {
48                self.auth_results.push_str(" header.i=");
49                self.auth_results.push_str(&signature.i);
50            } else {
51                self.auth_results.push_str(" header.d=");
52                self.auth_results.push_str(&signature.d);
53            }
54            self.auth_results.push_str(" header.s=");
55            self.auth_results.push_str(&signature.s);
56            if signature.b.len() >= 6 {
57                self.auth_results.push_str(" header.b=");
58                self.auth_results.push_str(
59                    &String::from_utf8(base64_encode(&signature.b[..6]).unwrap_or_default())
60                        .unwrap_or_default(),
61                );
62            }
63        }
64
65        if dkim.is_atps {
66            write!(self.auth_results, " header.from={header_from}").ok();
67        }
68    }
69
70    pub fn with_spf_ehlo_result(
71        mut self,
72        spf: &SpfOutput,
73        ip_addr: IpAddr,
74        ehlo_domain: &str,
75    ) -> Self {
76        self.auth_results.push_str(";\r\n\tspf=");
77        spf.result.as_spf_result(
78            &mut self.auth_results,
79            self.hostname,
80            &format!("postmaster@{ehlo_domain}"),
81            ip_addr,
82        );
83        write!(self.auth_results, " smtp.helo={ehlo_domain}").ok();
84        self
85    }
86
87    pub fn with_spf_mailfrom_result(
88        mut self,
89        spf: &SpfOutput,
90        ip_addr: IpAddr,
91        from: &str,
92        ehlo_domain: &str,
93    ) -> Self {
94        let (mail_from, addr) = if !from.is_empty() {
95            (Cow::from(from), from)
96        } else {
97            (format!("postmaster@{ehlo_domain}").into(), "<>")
98        };
99        self.auth_results.push_str(";\r\n\tspf=");
100        spf.result.as_spf_result(
101            &mut self.auth_results,
102            self.hostname,
103            mail_from.as_ref(),
104            ip_addr,
105        );
106        write!(self.auth_results, " smtp.mailfrom={addr}").ok();
107        self
108    }
109
110    pub fn with_arc_result(mut self, arc: &ArcOutput, remote_ip: IpAddr) -> Self {
111        self.auth_results.push_str(";\r\n\tarc=");
112        arc.result.as_auth_result(&mut self.auth_results);
113        let _ = write!(self.auth_results, " smtp.remote-ip=");
114        let _ = format_ip_as_pvalue(&mut self.auth_results, remote_ip);
115        self
116    }
117
118    pub fn with_dmarc_result(mut self, dmarc: &DmarcOutput) -> Self {
119        self.auth_results.push_str(";\r\n\tdmarc=");
120        if dmarc.spf_result == DmarcResult::Pass || dmarc.dkim_result == DmarcResult::Pass {
121            DmarcResult::Pass.as_auth_result(&mut self.auth_results);
122        } else if dmarc.spf_result != DmarcResult::None {
123            dmarc.spf_result.as_auth_result(&mut self.auth_results);
124        } else if dmarc.dkim_result != DmarcResult::None {
125            dmarc.dkim_result.as_auth_result(&mut self.auth_results);
126        } else {
127            DmarcResult::None.as_auth_result(&mut self.auth_results);
128        }
129        write!(
130            self.auth_results,
131            " header.from={} policy.dmarc={}",
132            dmarc.domain, dmarc.policy
133        )
134        .ok();
135        self
136    }
137
138    pub fn with_iprev_result(mut self, iprev: &IprevOutput, remote_ip: IpAddr) -> Self {
139        self.auth_results.push_str(";\r\n\tiprev=");
140        iprev.result.as_auth_result(&mut self.auth_results);
141        let _ = write!(self.auth_results, " policy.iprev=");
142        let _ = format_ip_as_pvalue(&mut self.auth_results, remote_ip);
143        self
144    }
145}
146
147impl Display for AuthenticationResults<'_> {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.write_str(self.hostname)?;
150        f.write_str(&self.auth_results)
151    }
152}
153
154impl HeaderWriter for AuthenticationResults<'_> {
155    fn write_header(&self, writer: &mut impl Writer) {
156        writer.write(b"Authentication-Results: ");
157        writer.write(self.hostname.as_bytes());
158        if !self.auth_results.is_empty() {
159            writer.write(self.auth_results.as_bytes());
160        } else {
161            writer.write(b"; none");
162        }
163        writer.write(b"\r\n");
164    }
165}
166
167impl HeaderWriter for ReceivedSpf {
168    fn write_header(&self, writer: &mut impl Writer) {
169        writer.write(b"Received-SPF: ");
170        writer.write(self.received_spf.as_bytes());
171        writer.write(b"\r\n");
172    }
173}
174
175impl ReceivedSpf {
176    pub fn new(
177        spf: &SpfOutput,
178        ip_addr: IpAddr,
179        helo: &str,
180        mail_from: &str,
181        hostname: &str,
182    ) -> Self {
183        let mut received_spf = String::with_capacity(64);
184        let mail_from = if !mail_from.is_empty() {
185            Cow::from(mail_from)
186        } else {
187            format!("postmaster@{helo}").into()
188        };
189
190        spf.result
191            .as_spf_result(&mut received_spf, hostname, mail_from.as_ref(), ip_addr);
192
193        write!(
194            received_spf,
195            "\r\n\treceiver={hostname}; client-ip={ip_addr}; envelope-from=\"{mail_from}\"; helo={helo};",
196        )
197        .ok();
198
199        ReceivedSpf { received_spf }
200    }
201}
202
203impl SpfResult {
204    fn as_spf_result(&self, header: &mut String, hostname: &str, mail_from: &str, ip_addr: IpAddr) {
205        match &self {
206            SpfResult::Pass => write!(
207                header,
208                "pass ({hostname}: domain of {mail_from} designates {ip_addr} as permitted sender)",
209            ),
210            SpfResult::Fail => write!(
211                header,
212                "fail ({hostname}: domain of {mail_from} does not designate {ip_addr} as permitted sender)",
213            ),
214            SpfResult::SoftFail => write!(
215                header,
216                "softfail ({hostname}: domain of {mail_from} reports soft fail for {ip_addr})",
217            ),
218            SpfResult::Neutral => write!(
219                header,
220                "neutral ({hostname}: domain of {mail_from} reports neutral for {ip_addr})",
221            ),
222            SpfResult::TempError => write!(
223                header,
224                "temperror ({hostname}: temporary dns error validating {mail_from})",
225            ),
226            SpfResult::PermError => write!(
227                header,
228                "permerror ({hostname}: unable to verify SPF record for {mail_from})",
229            ),
230            SpfResult::None => write!(
231                header,
232                "none ({hostname}: no SPF records found for {mail_from})",
233            ),
234        }
235        .ok();
236    }
237}
238
239pub trait AsAuthResult {
240    fn as_auth_result(&self, header: &mut String);
241}
242
243impl AsAuthResult for DmarcResult {
244    fn as_auth_result(&self, header: &mut String) {
245        match &self {
246            DmarcResult::Pass => header.push_str("pass"),
247            DmarcResult::Fail(err) => {
248                header.push_str("fail");
249                err.as_auth_result(header);
250            }
251            DmarcResult::PermError(err) => {
252                header.push_str("permerror");
253                err.as_auth_result(header);
254            }
255            DmarcResult::TempError(err) => {
256                header.push_str("temperror");
257                err.as_auth_result(header);
258            }
259            DmarcResult::None => header.push_str("none"),
260        }
261    }
262}
263
264impl AsAuthResult for IprevResult {
265    fn as_auth_result(&self, header: &mut String) {
266        match &self {
267            IprevResult::Pass => header.push_str("pass"),
268            IprevResult::Fail(err) => {
269                header.push_str("fail");
270                err.as_auth_result(header);
271            }
272            IprevResult::PermError(err) => {
273                header.push_str("permerror");
274                err.as_auth_result(header);
275            }
276            IprevResult::TempError(err) => {
277                header.push_str("temperror");
278                err.as_auth_result(header);
279            }
280            IprevResult::None => header.push_str("none"),
281        }
282    }
283}
284
285impl AsAuthResult for DkimResult {
286    fn as_auth_result(&self, header: &mut String) {
287        match &self {
288            DkimResult::Pass => header.push_str("pass"),
289            DkimResult::Neutral(err) => {
290                header.push_str("neutral");
291                err.as_auth_result(header);
292            }
293            DkimResult::Fail(err) => {
294                header.push_str("fail");
295                err.as_auth_result(header);
296            }
297            DkimResult::PermError(err) => {
298                header.push_str("permerror");
299                err.as_auth_result(header);
300            }
301            DkimResult::TempError(err) => {
302                header.push_str("temperror");
303                err.as_auth_result(header);
304            }
305            DkimResult::None => header.push_str("none"),
306        }
307    }
308}
309
310impl AsAuthResult for Error {
311    fn as_auth_result(&self, header: &mut String) {
312        header.push_str(" (");
313        header.push_str(match self {
314            Error::ParseError => "dns record parse error",
315            Error::MissingParameters => "missing parameters",
316            Error::NoHeadersFound => "no headers found",
317            Error::CryptoError(_) => "verification failed",
318            Error::Io(_) => "i/o error",
319            Error::Base64 => "base64 error",
320            Error::UnsupportedVersion => "unsupported version",
321            Error::UnsupportedAlgorithm => "unsupported algorithm",
322            Error::UnsupportedCanonicalization => "unsupported canonicalization",
323            Error::UnsupportedKeyType => "unsupported key type",
324            Error::FailedBodyHashMatch => "body hash did not verify",
325            Error::FailedVerification => "verification failed",
326            Error::FailedAuidMatch => "auid does not match",
327            Error::RevokedPublicKey => "revoked public key",
328            Error::IncompatibleAlgorithms => "incompatible record/signature algorithms",
329            Error::SignatureExpired => "signature error",
330            Error::DnsError(_) => "dns error",
331            Error::DnsRecordNotFound(_) => "dns record not found",
332            Error::ArcInvalidInstance(i) => {
333                write!(header, "invalid ARC instance {i})").ok();
334                return;
335            }
336            Error::ArcInvalidCV => "invalid ARC cv",
337            Error::ArcChainTooLong => "too many ARC headers",
338            Error::ArcHasHeaderTag => "ARC has header tag",
339            Error::ArcBrokenChain => "broken ARC chain",
340            Error::NotAligned => "policy not aligned",
341            Error::InvalidRecordType => "invalid dns record type",
342            Error::SignatureLength => "signature length ignored due to security risk",
343        });
344        header.push(')');
345    }
346}
347
348/// Encodes the IP address to be used in a [`pvalue`] field.
349///
350/// IPv4 addresses can be used as-is, but IPv6 addresses need to be quoted
351/// since they contain `:` characters.
352///
353/// [`pvalue`]: https://datatracker.ietf.org/doc/html/rfc8601#section-2.2
354fn format_ip_as_pvalue(w: &mut impl Write, ip: IpAddr) -> std::fmt::Result {
355    match ip {
356        IpAddr::V4(addr) => write!(w, "{addr}"),
357        IpAddr::V6(addr) => write!(w, "\"{addr}\""),
358    }
359}
360
361#[cfg(test)]
362mod test {
363    use crate::{
364        ArcOutput, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
365        IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult, dkim::Signature,
366        dmarc::Policy,
367    };
368
369    #[test]
370    fn authentication_results() {
371        let mut auth_results = AuthenticationResults::new("mydomain.org");
372
373        for (expected_auth_results, dkim) in [
374            (
375                "dkim=pass header.d=example.org header.s=myselector",
376                DkimOutput {
377                    result: DkimResult::Pass,
378                    signature: (&Signature {
379                        d: "example.org".into(),
380                        s: "myselector".into(),
381                        ..Default::default()
382                    })
383                        .into(),
384                    report: None,
385                    is_atps: false,
386                },
387            ),
388            (
389                concat!(
390                    "dkim=fail (verification failed) header.d=example.org ",
391                    "header.s=myselector header.b=MTIzNDU2"
392                ),
393                DkimOutput {
394                    result: DkimResult::Fail(Error::FailedVerification),
395                    signature: (&Signature {
396                        d: "example.org".into(),
397                        s: "myselector".into(),
398                        b: b"123456".to_vec(),
399                        ..Default::default()
400                    })
401                        .into(),
402                    report: None,
403                    is_atps: false,
404                },
405            ),
406            (
407                concat!(
408                    "dkim-atps=temperror (dns error) header.d=atps.example.org ",
409                    "header.s=otherselctor header.b=YWJjZGVm header.from=jdoe@example.org"
410                ),
411                DkimOutput {
412                    result: DkimResult::TempError(Error::DnsError("".to_string())),
413                    signature: (&Signature {
414                        d: "atps.example.org".into(),
415                        s: "otherselctor".into(),
416                        b: b"abcdef".to_vec(),
417                        ..Default::default()
418                    })
419                        .into(),
420                    report: None,
421                    is_atps: true,
422                },
423            ),
424        ] {
425            auth_results = auth_results.with_dkim_results(&[dkim], "jdoe@example.org");
426            assert_eq!(
427                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
428                expected_auth_results
429            );
430        }
431
432        for (
433            expected_auth_results,
434            expected_received_spf,
435            result,
436            ip_addr,
437            receiver,
438            helo,
439            mail_from,
440        ) in [
441            (
442                concat!(
443                    "spf=pass (localhost: domain of jdoe@example.org designates 192.168.1.1 ",
444                    "as permitted sender) smtp.mailfrom=jdoe@example.org"
445                ),
446                concat!(
447                    "pass (localhost: domain of jdoe@example.org designates 192.168.1.1 as ",
448                    "permitted sender)\r\n\treceiver=localhost; client-ip=192.168.1.1; ",
449                    "envelope-from=\"jdoe@example.org\"; helo=example.org;"
450                ),
451                SpfResult::Pass,
452                "192.168.1.1".parse().unwrap(),
453                "localhost",
454                "example.org",
455                "jdoe@example.org",
456            ),
457            (
458                concat!(
459                    "spf=fail (mx.domain.org: domain of sender@otherdomain.org does not ",
460                    "designate a:b:c::f as permitted sender) smtp.mailfrom=sender@otherdomain.org"
461                ),
462                concat!(
463                    "fail (mx.domain.org: domain of sender@otherdomain.org does not designate ",
464                    "a:b:c::f as permitted sender)\r\n\treceiver=mx.domain.org; ",
465                    "client-ip=a:b:c::f; envelope-from=\"sender@otherdomain.org\"; ",
466                    "helo=otherdomain.org;"
467                ),
468                SpfResult::Fail,
469                "a:b:c::f".parse().unwrap(),
470                "mx.domain.org",
471                "otherdomain.org",
472                "sender@otherdomain.org",
473            ),
474            (
475                concat!(
476                    "spf=neutral (mx.domain.org: domain of postmaster@example.org reports neutral ",
477                    "for a:b:c::f) smtp.mailfrom=<>"
478                ),
479                concat!(
480                    "neutral (mx.domain.org: domain of postmaster@example.org reports neutral for ",
481                    "a:b:c::f)\r\n\treceiver=mx.domain.org; client-ip=a:b:c::f; ",
482                    "envelope-from=\"postmaster@example.org\"; helo=example.org;"
483                ),
484                SpfResult::Neutral,
485                "a:b:c::f".parse().unwrap(),
486                "mx.domain.org",
487                "example.org",
488                "",
489            ),
490        ] {
491            auth_results.hostname = receiver;
492            auth_results = auth_results.with_spf_mailfrom_result(
493                &SpfOutput {
494                    result,
495                    domain: "".to_string(),
496                    report: None,
497                    explanation: None,
498                },
499                ip_addr,
500                mail_from,
501                helo,
502            );
503            let received_spf = ReceivedSpf::new(
504                &SpfOutput {
505                    result,
506                    domain: "".to_string(),
507                    report: None,
508                    explanation: None,
509                },
510                ip_addr,
511                helo,
512                mail_from,
513                receiver,
514            );
515            assert_eq!(
516                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
517                expected_auth_results
518            );
519            assert_eq!(received_spf.received_spf, expected_received_spf);
520        }
521
522        for (expected_auth_results, dmarc) in [
523            (
524                "dmarc=pass header.from=example.org policy.dmarc=none",
525                DmarcOutput {
526                    spf_result: DmarcResult::Pass,
527                    dkim_result: DmarcResult::None,
528                    domain: "example.org".to_string(),
529                    policy: Policy::None,
530                    record: None,
531                },
532            ),
533            (
534                "dmarc=fail (policy not aligned) header.from=example.com policy.dmarc=quarantine",
535                DmarcOutput {
536                    dkim_result: DmarcResult::Fail(Error::NotAligned),
537                    spf_result: DmarcResult::None,
538                    domain: "example.com".to_string(),
539                    policy: Policy::Quarantine,
540                    record: None,
541                },
542            ),
543        ] {
544            auth_results = auth_results.with_dmarc_result(&dmarc);
545            assert_eq!(
546                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
547                expected_auth_results
548            );
549        }
550
551        for (expected_auth_results, arc, remote_ip) in [
552            (
553                "arc=pass smtp.remote-ip=192.127.9.2",
554                DkimResult::Pass,
555                "192.127.9.2".parse().unwrap(),
556            ),
557            (
558                "arc=neutral (body hash did not verify) smtp.remote-ip=\"1:2:3::a\"",
559                DkimResult::Neutral(Error::FailedBodyHashMatch),
560                "1:2:3::a".parse().unwrap(),
561            ),
562        ] {
563            auth_results = auth_results.with_arc_result(
564                &ArcOutput {
565                    result: arc,
566                    set: vec![],
567                },
568                remote_ip,
569            );
570            assert_eq!(
571                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
572                expected_auth_results
573            );
574        }
575
576        for (expected_auth_results, iprev, remote_ip) in [
577            (
578                "iprev=pass policy.iprev=192.127.9.2",
579                IprevOutput {
580                    result: IprevResult::Pass,
581                    ptr: None,
582                },
583                "192.127.9.2".parse().unwrap(),
584            ),
585            (
586                "iprev=fail (policy not aligned) policy.iprev=\"1:2:3::a\"",
587                IprevOutput {
588                    result: IprevResult::Fail(Error::NotAligned),
589                    ptr: None,
590                },
591                "1:2:3::a".parse().unwrap(),
592            ),
593        ] {
594            auth_results = auth_results.with_iprev_result(&iprev, remote_ip);
595            assert_eq!(
596                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
597                expected_auth_results
598            );
599        }
600    }
601}