Skip to main content

mail_auth/dkim2/
output.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//! Verification results: [`Dkim2Result`], [`Dkim2Output`] and [`ChainLink`].
8
9use super::{Flag, MessageInstance, Signature};
10use crate::{AuthenticatedMessage, DnsError, Error, headers::Header};
11
12/// Outcome of a DKIM2 verification (§14.1).
13///
14/// The error carried by the failure variants is usually an [`Error::Dkim2`]
15/// naming the check that failed.
16#[derive(Debug, PartialEq, Eq, Clone)]
17pub enum Dkim2Result {
18    /// The message is a Fully Verified DKIM2 Message: every signature
19    /// verified, every hash matched, the most recent signature matches the
20    /// SMTP envelope, the chain of custody is unbroken and every `From`
21    /// header field is Authentic. For a provisional verification, the message
22    /// is Provisionally Verified (§13.2).
23    Pass,
24    /// Verification completed but the message is not a Fully Verified DKIM2
25    /// Message: a signature or a hash was not correct, a timestamp is out of
26    /// range, the envelope or chain of custody does not match, or a `From`
27    /// header field is not Authentic.
28    Fail(crate::Error),
29    /// The chain cannot be verified because of an unrecoverable problem, such
30    /// as a malformed or missing header field or tag, a gap in the
31    /// numbering, an algorithm that is not implemented, or a missing,
32    /// invalid or revoked public key.
33    PermError(crate::Error),
34    /// A public key could not be fetched because of a temporary DNS error; a
35    /// later attempt may succeed.
36    TempError(crate::Error),
37    /// The message has no `DKIM2-Signature` and no `Message-Instance` header
38    /// field, so there was nothing to verify. This follows the `none` result
39    /// of DKIM and ARC; §14.2 reports it as a PERMERROR ("No DKIM2 header
40    /// fields found").
41    None,
42}
43
44impl From<Error> for Dkim2Result {
45    fn from(err: Error) -> Self {
46        if matches!(&err, Error::Dns(DnsError::Resolver(_))) {
47            Dkim2Result::TempError(err)
48        } else {
49            Dkim2Result::PermError(err)
50        }
51    }
52}
53
54/// Which checks a DKIM2 verification performed.
55#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
56pub enum Verification {
57    /// Every check of §14: the message is a Fully Verified DKIM2 Message
58    /// when the result is [`Dkim2Result::Pass`].
59    #[default]
60    Full,
61    /// Only the checks for a Provisionally Verified message (§13.2): header
62    /// field validity, timestamps, the envelope against the most recent
63    /// signature, that signature and the hashes of the most recent
64    /// `Message-Instance`.
65    Provisional,
66}
67
68/// Result of [`MessageAuthenticator::verify_dkim2`](crate::MessageAuthenticator::verify_dkim2)
69/// and [`MessageAuthenticator::verify_dkim2_provisionally`](crate::MessageAuthenticator::verify_dkim2_provisionally).
70///
71/// Holds the overall [`Dkim2Result`] and, when the chain passes, one
72/// [`ChainLink`] per signature. The links borrow from the verified
73/// [`AuthenticatedMessage`](crate::AuthenticatedMessage). A passing full
74/// verification is the proof of validity that
75/// [`Dkim2Signer`](super::Dkim2Signer) requires to sign a message that
76/// already carries DKIM2 header fields (§12).
77#[derive(Debug, PartialEq, Eq, Clone)]
78pub struct Dkim2Output<'x> {
79    pub(crate) result: Dkim2Result,
80    pub(crate) chain: Vec<ChainLink<'x>>,
81    pub(crate) verification: Verification,
82    pub(crate) raw_message: &'x [u8],
83    pub(crate) signature_fields: &'x [Header<'x, Signature>],
84    pub(crate) instance_fields: &'x [Header<'x, MessageInstance>],
85}
86
87/// One verified hop of a DKIM2 chain, in ascending `i=` order.
88#[derive(Debug, PartialEq, Eq, Clone)]
89pub struct ChainLink<'x> {
90    /// The `DKIM2-Signature` of this hop.
91    pub signature: &'x Signature,
92    /// The `Message-Instance` whose `m=` equals the signature's `m=`, if
93    /// present.
94    pub instance: Option<&'x MessageInstance>,
95    /// Verification result of this hop.
96    pub result: Dkim2Result,
97    /// Whether this hop continues the chain of custody of the previous one.
98    pub custody_ok: bool,
99}
100
101impl<'x> Dkim2Output<'x> {
102    /// Returns the overall verification result.
103    pub fn result(&self) -> &Dkim2Result {
104        &self.result
105    }
106
107    /// Returns which checks were performed.
108    pub fn verification(&self) -> Verification {
109        self.verification
110    }
111
112    /// Returns the verified hops in ascending `i=` order. Empty unless
113    /// [`result`](Self::result) is [`Dkim2Result::Pass`]; after a
114    /// provisional verification it holds only the most recent signature.
115    pub fn chain(&self) -> &[ChainLink<'x>] {
116        &self.chain
117    }
118
119    /// Returns the error carried by a `Fail`, `PermError` or `TempError`
120    /// result, or `None` for `Pass` and `None`.
121    pub fn error(&self) -> Option<&crate::Error> {
122        match &self.result {
123            Dkim2Result::Fail(err) | Dkim2Result::PermError(err) | Dkim2Result::TempError(err) => {
124                Some(err)
125            }
126            Dkim2Result::Pass | Dkim2Result::None => None,
127        }
128    }
129
130    /// Returns true if any signer in the verified chain requested feedback
131    /// with the `feedback` flag (§10.10).
132    pub fn feedback_requested(&self) -> bool {
133        self.chain
134            .iter()
135            .any(|link| link.signature.flags.contains(&Flag::Feedback))
136    }
137
138    /// Returns the signing domains (`d=`) of the signatures that set the
139    /// `feedback` flag, in ascending `i=` order.
140    pub fn feedback_domains(&self) -> impl Iterator<Item = &str> {
141        self.chain
142            .iter()
143            .filter(|link| link.signature.flags.contains(&Flag::Feedback))
144            .map(|link| link.signature.d.as_str())
145    }
146
147    /// Returns the signing domain of the most recent hop that set the
148    /// `feedhere` flag, or `None` if no hop did.
149    ///
150    /// A privacy-conscious Forwarder sets `feedhere` so that feedback is
151    /// relayed through it instead of being sent directly to the requestor
152    /// (§10.10).
153    pub fn feedback_relay(&self) -> Option<&str> {
154        self.chain
155            .iter()
156            .filter(|link| link.signature.flags.contains(&Flag::FeedHere))
157            .max_by_key(|link| link.signature.i)
158            .map(|link| link.signature.d.as_str())
159    }
160
161    pub(crate) fn is_full_pass(&self) -> bool {
162        matches!(self.result, Dkim2Result::Pass) && self.verification == Verification::Full
163    }
164
165    pub(crate) fn is_proof_for(&self, message: &AuthenticatedMessage<'_>) -> bool {
166        self.is_full_pass()
167            && !message.has_dkim2_errors
168            && same_values(self.signature_fields, &message.dkim2_signatures)
169            && same_values(self.instance_fields, &message.dkim2_instances)
170    }
171
172    pub(crate) fn covers(&self, raw_message: &[u8]) -> bool {
173        !self.raw_message.is_empty() && std::ptr::eq(self.raw_message, raw_message)
174    }
175}
176
177fn same_values<T>(proof: &[Header<'_, T>], message: &[Header<'_, T>]) -> bool {
178    proof.len() == message.len()
179        && proof
180            .iter()
181            .zip(message)
182            .all(|(proof, message)| proof.value == message.value)
183}
184
185impl From<Dkim2Result> for Dkim2Output<'_> {
186    fn from(result: Dkim2Result) -> Self {
187        Dkim2Output {
188            result,
189            chain: Vec::new(),
190            verification: Verification::Full,
191            raw_message: &[],
192            signature_fields: &[],
193            instance_fields: &[],
194        }
195    }
196}
197
198#[cfg(test)]
199mod test {
200    use super::*;
201    use crate::dkim2::{Flag, Signature};
202
203    fn signed(i: u32, domain: &str, flags: Vec<Flag>) -> Signature {
204        Signature {
205            i,
206            d: domain.to_string(),
207            flags,
208            ..Default::default()
209        }
210    }
211
212    #[test]
213    fn feedback_accessors() {
214        let sig1 = signed(1, "a.example", vec![Flag::Feedback]);
215        let sig2 = signed(2, "b.example", vec![Flag::Feedback, Flag::FeedHere]);
216        let link = |signature| ChainLink {
217            signature,
218            instance: None,
219            result: Dkim2Result::Pass,
220            custody_ok: true,
221        };
222        let output = Dkim2Output {
223            chain: vec![link(&sig1), link(&sig2)],
224            ..Dkim2Output::from(Dkim2Result::Pass)
225        };
226
227        assert!(output.feedback_requested());
228        assert_eq!(
229            output.feedback_domains().collect::<Vec<_>>(),
230            vec!["a.example", "b.example"]
231        );
232        assert_eq!(output.feedback_relay(), Some("b.example"));
233
234        let none = Dkim2Output::from(Dkim2Result::Pass);
235        assert!(!none.feedback_requested());
236        assert!(none.feedback_domains().next().is_none());
237        assert_eq!(none.feedback_relay(), None);
238    }
239}