Skip to main content

mailrs_arc/
verify.rs

1//! ARC chain verification (RFC 8617 §5).
2//!
3//! Verification has two layers:
4//!
5//! 1. **Structural** — every set is complete and contiguous (handled
6//!    by [`ArcChain::extract`]); `cv=` values are internally consistent
7//!    (first set must be `cv=none`, every later set must be `cv=pass`
8//!    or `cv=fail`); chain length ≤ 50.
9//! 2. **Cryptographic** — for each instance from highest to lowest:
10//!    verify the AMS over the (canonicalized) message + signed
11//!    headers, then verify the AS over the (canonicalized) chain
12//!    prefix. Both use DKIM's canonicalization rules and the same
13//!    signature algorithms (RSA-SHA256 / Ed25519-SHA256) per
14//!    RFC 8617 §5.
15//!
16//! Since 1.1, [`verify_chain_with_crypto`] runs the full crypto layer:
17//! it walks the chain from the highest instance down, verifying each
18//! AMS + AS against DNS-fetched keys via [`crate::crypto`]. The
19//! structural-only variant [`verify_chain`] remains available for
20//! callers that want early rejection before any DNS lookup.
21
22use crate::chain::ArcChain;
23use crate::error::ArcError;
24use crate::header::{ArcSealCv, MAX_INSTANCE};
25use crate::resolver::ArcResolver;
26
27/// Outcome of [`verify_chain`].
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ChainOutcome {
30    /// All structural checks pass. The chain is well-formed and the
31    /// `cv=` integrity rules hold. Use the chain's accumulated
32    /// `Authentication-Results` for downstream DMARC.
33    Pass,
34    /// The chain has at least one violation. `reason` is a short
35    /// human-readable explanation suitable for the `arc=fail reason="…"`
36    /// authres field per RFC 8601.
37    Fail {
38        /// Short fail reason for AuthResults output.
39        reason: String,
40    },
41    /// Structural layer passed; cryptographic layer not yet
42    /// implemented. Returned by [`verify_chain_with_crypto`] only when
43    /// it is invoked from a path that requested structural-only
44    /// (none of the current public APIs return this — it is retained
45    /// for compatibility with 1.0 callers that pattern-matched on it).
46    #[deprecated(
47        since = "1.1.0",
48        note = "verify_chain_with_crypto now performs cryptographic verification; \
49                this variant is no longer returned"
50    )]
51    CryptoUnimplemented,
52}
53
54/// Verify the structural integrity of an ARC chain.
55///
56/// Does not perform DNS lookups or signature crypto in 1.0; that's
57/// the job of [`verify_chain_with_crypto`] in 1.1. Even just the
58/// structural verdict is useful: a chain that violates §5.1
59/// integrity rules can be rejected before any crypto work.
60pub fn verify_chain(chain: &ArcChain) -> ChainOutcome {
61    if chain.sets.is_empty() {
62        return ChainOutcome::Fail {
63            reason: "empty chain".into(),
64        };
65    }
66    if chain.sets.len() > MAX_INSTANCE as usize {
67        return ChainOutcome::Fail {
68            reason: format!("chain length {} exceeds RFC 8617 §4.2.1 max of 50", chain.sets.len()),
69        };
70    }
71    // First set must have cv=none; every later set must have cv=pass
72    // or cv=fail. RFC 8617 §5.1.
73    for (idx, set) in chain.sets.iter().enumerate() {
74        if idx == 0 {
75            if set.seal.cv != ArcSealCv::None {
76                return ChainOutcome::Fail {
77                    reason: format!(
78                        "first ARC-Seal must have cv=none, got cv={:?}",
79                        set.seal.cv
80                    ),
81                };
82            }
83        } else if set.seal.cv == ArcSealCv::None {
84            return ChainOutcome::Fail {
85                reason: format!("ARC-Seal i={} has cv=none but is not the first set", set.i),
86            };
87        }
88    }
89    // AMS and AS at every instance must reference an instance that
90    // matches the set's i= — already guaranteed by ArcChain::extract,
91    // which keys the map on the parsed i=. Double-check for paranoia.
92    for (idx, set) in chain.sets.iter().enumerate() {
93        let expected = (idx + 1) as u32;
94        if set.i != expected || set.aar.instance != expected || set.ams.instance != expected
95            || set.seal.instance != expected
96        {
97            return ChainOutcome::Fail {
98                reason: format!("ARC set {idx} has mismatched i= across its 3 headers"),
99            };
100        }
101    }
102    ChainOutcome::Pass
103}
104
105/// Full ARC chain verification including cryptographic checks.
106///
107/// For each set from highest instance down, verifies (a) the AMS
108/// against the message body + signed headers, and (b) the AS against
109/// the chain prefix per RFC 8617 §5.1.2. A single failure returns
110/// [`ChainOutcome::Fail`] with a short reason; the chain only
111/// achieves [`ChainOutcome::Pass`] when every AMS and every AS
112/// verifies cryptographically.
113///
114/// Crypto delegates to [`crate::crypto`] which re-uses
115/// [`mailrs_dkim::crypto`] for the actual RSA-SHA256 /
116/// Ed25519-SHA256 primitive.
117pub async fn verify_chain_with_crypto<R: ArcResolver + ?Sized>(
118    chain: &ArcChain,
119    resolver: &R,
120    raw_message: &[u8],
121) -> Result<ChainOutcome, ArcError> {
122    match verify_chain(chain) {
123        ChainOutcome::Pass => {}
124        other => return Ok(other),
125    }
126    // Walk highest → lowest; a tampered later set is the most useful
127    // signal for downstream DMARC (the latest forwarder is who
128    // attaches the chain we trust). For each instance verify AMS then
129    // AS — both must pass.
130    for set in chain.sets.iter().rev() {
131        if let Err(e) = crate::crypto::verify_ams(set, raw_message, resolver).await {
132            return Ok(ChainOutcome::Fail {
133                reason: format!("ams i={}: {e}", set.i),
134            });
135        }
136        if let Err(e) = crate::crypto::verify_as(chain, set.i, resolver).await {
137            return Ok(ChainOutcome::Fail {
138                reason: format!("as i={}: {e}", set.i),
139            });
140        }
141    }
142    Ok(ChainOutcome::Pass)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::chain::ArcChain;
149
150    fn msg_with(headers: &[&str]) -> Vec<u8> {
151        let mut v = Vec::new();
152        for h in headers {
153            v.extend_from_slice(h.as_bytes());
154        }
155        v.extend_from_slice(b"From: a@b.c\r\nSubject: t\r\n\r\nbody");
156        v
157    }
158
159    const SET1_AAR: &str = "ARC-Authentication-Results: i=1; spf=pass\r\n";
160    const SET1_AMS: &str = "ARC-Message-Signature: i=1; a=rsa-sha256; d=example.com; s=mail; h=From; bh=BH1; b=SIG1\r\n";
161    const SET1_AS_NONE: &str = "ARC-Seal: i=1; a=rsa-sha256; cv=none; d=example.com; s=mail; b=SEAL1\r\n";
162    const SET1_AS_PASS: &str = "ARC-Seal: i=1; a=rsa-sha256; cv=pass; d=example.com; s=mail; b=SEAL1\r\n";
163
164    const SET2_AAR: &str = "ARC-Authentication-Results: i=2; dkim=pass\r\n";
165    const SET2_AMS: &str = "ARC-Message-Signature: i=2; a=rsa-sha256; d=forwarder.example; s=mail; h=From; bh=BH2; b=SIG2\r\n";
166    const SET2_AS_PASS: &str = "ARC-Seal: i=2; a=rsa-sha256; cv=pass; d=forwarder.example; s=mail; b=SEAL2\r\n";
167    const SET2_AS_NONE: &str = "ARC-Seal: i=2; a=rsa-sha256; cv=none; d=forwarder.example; s=mail; b=SEAL2\r\n";
168
169    #[test]
170    fn single_set_with_cv_none_passes_structural() {
171        let m = msg_with(&[SET1_AAR, SET1_AMS, SET1_AS_NONE]);
172        let chain = ArcChain::extract(&m).unwrap().unwrap();
173        assert_eq!(verify_chain(&chain), ChainOutcome::Pass);
174    }
175
176    #[test]
177    fn single_set_with_cv_pass_fails_structural() {
178        // First set must be cv=none.
179        let m = msg_with(&[SET1_AAR, SET1_AMS, SET1_AS_PASS]);
180        let chain = ArcChain::extract(&m).unwrap().unwrap();
181        assert!(matches!(verify_chain(&chain), ChainOutcome::Fail { .. }));
182    }
183
184    #[test]
185    fn two_set_normal_chain_passes_structural() {
186        let m = msg_with(&[SET1_AAR, SET1_AMS, SET1_AS_NONE, SET2_AAR, SET2_AMS, SET2_AS_PASS]);
187        let chain = ArcChain::extract(&m).unwrap().unwrap();
188        assert_eq!(verify_chain(&chain), ChainOutcome::Pass);
189    }
190
191    #[test]
192    fn later_set_with_cv_none_fails_structural() {
193        // Second set with cv=none is illegal.
194        let m = msg_with(&[SET1_AAR, SET1_AMS, SET1_AS_NONE, SET2_AAR, SET2_AMS, SET2_AS_NONE]);
195        let chain = ArcChain::extract(&m).unwrap().unwrap();
196        assert!(matches!(verify_chain(&chain), ChainOutcome::Fail { .. }));
197    }
198
199    struct DummyResolver;
200    #[async_trait::async_trait]
201    impl mailrs_dkim::DkimResolver for DummyResolver {
202        async fn lookup_txt(&self, _: &str) -> Result<Vec<String>, mailrs_dkim::DkimError> {
203            Ok(vec![])
204        }
205    }
206
207    #[tokio::test]
208    async fn crypto_verify_returns_fail_when_dns_empty_after_structural_pass() {
209        // Structural layer passes, but the DummyResolver returns an
210        // empty TXT vector — fetch_public_key bubbles that up as Dns,
211        // which verify_chain_with_crypto maps to ChainOutcome::Fail
212        // with a "ams i=1: …" reason.
213        let m = msg_with(&[SET1_AAR, SET1_AMS, SET1_AS_NONE]);
214        let chain = ArcChain::extract(&m).unwrap().unwrap();
215        let r = verify_chain_with_crypto(&chain, &DummyResolver, &m)
216            .await
217            .unwrap();
218        match r {
219            ChainOutcome::Fail { reason } => assert!(reason.starts_with("ams i=1:"), "{reason}"),
220            other => panic!("expected Fail, got {other:?}"),
221        }
222    }
223
224    #[tokio::test]
225    async fn crypto_verify_returns_fail_when_structural_fails() {
226        // First-set cv=pass — fails structural; verify_chain_with_crypto
227        // must propagate that.
228        let m = msg_with(&[SET1_AAR, SET1_AMS, SET1_AS_PASS]);
229        let chain = ArcChain::extract(&m).unwrap().unwrap();
230        let r = verify_chain_with_crypto(&chain, &DummyResolver, &m)
231            .await
232            .unwrap();
233        assert!(matches!(r, ChainOutcome::Fail { .. }));
234    }
235}