Skip to main content

email_auth/arc/
validate.rs

1use crate::common::dns::{DnsError, DnsResolver};
2use crate::dkim::canon::{
3    apply_body_length_limit, canonicalize_body, canonicalize_header, normalize_line_endings,
4    select_headers, strip_b_tag_value,
5};
6use crate::dkim::key::DkimPublicKey;
7use crate::dkim::types::CanonicalizationMethod;
8use crate::dkim::verify::{compute_hash, verify_signature};
9
10use subtle::ConstantTimeEq;
11
12use super::parser::collect_arc_sets;
13use super::types::{
14    ArcMessageSignature, ArcResult, ArcSeal, ArcSet, ArcValidationResult,
15    ChainValidationStatus,
16};
17
18/// ARC chain verifier.
19pub struct ArcVerifier<R: DnsResolver> {
20    resolver: R,
21}
22
23impl<R: DnsResolver> ArcVerifier<R> {
24    pub fn new(resolver: R) -> Self {
25        Self { resolver }
26    }
27
28    /// Validate the ARC chain in a message.
29    pub async fn validate_chain(
30        &self,
31        headers: &[(&str, &str)],
32        body: &[u8],
33    ) -> ArcValidationResult {
34        // Step 1: Collect ARC Sets
35        let sets = match collect_arc_sets(headers) {
36            Ok(s) => s,
37            Err(e) => {
38                return ArcValidationResult {
39                    status: ArcResult::Fail {
40                        reason: e.detail,
41                    },
42                    oldest_pass: Option::None,
43                };
44            }
45        };
46
47        // No ARC headers → None
48        if sets.is_empty() {
49            return ArcValidationResult {
50                status: ArcResult::None,
51                oldest_pass: Option::None,
52            };
53        }
54
55        let n = sets.len();
56
57        // Step 1b: >50 sets
58        if n > 50 {
59            return ArcValidationResult {
60                status: ArcResult::Fail {
61                    reason: format!("too many ARC sets: {}", n),
62                },
63                oldest_pass: Option::None,
64            };
65        }
66
67        // Step 2: Check latest cv value
68        let latest = &sets[n - 1];
69        if latest.seal.cv == ChainValidationStatus::Fail {
70            return ArcValidationResult {
71                status: ArcResult::Fail {
72                    reason: format!("AS({}) has cv=fail", n),
73                },
74                oldest_pass: Option::None,
75            };
76        }
77
78        // Step 3: Validate structure
79        if let Err(reason) = validate_structure(&sets) {
80            return ArcValidationResult {
81                status: ArcResult::Fail { reason },
82                oldest_pass: Option::None,
83            };
84        }
85
86        // Step 4: Validate most recent AMS (N)
87        if let Err(reason) = self.validate_ams(&sets[n - 1].ams, headers, body).await {
88            return ArcValidationResult {
89                status: ArcResult::Fail {
90                    reason: format!("AMS({}) validation failed: {}", n, reason),
91                },
92                oldest_pass: Option::None,
93            };
94        }
95
96        // Step 5: Determine oldest-pass (optional, validate remaining AMS)
97        // RFC 8617 §5.2(5)(B): if all AMS validations pass, oldest_pass = 0
98        let mut oldest_pass: u32 = 0;
99        for i in (0..n - 1).rev() {
100            if let Err(_) = self.validate_ams(&sets[i].ams, headers, body).await {
101                oldest_pass = (i + 2) as u32; // i is 0-based, instance is 1-based
102                break;
103            }
104        }
105
106        // Step 6: Validate all AS headers
107        for i in (0..n).rev() {
108            if let Err(reason) = self.validate_seal(&sets[i].seal, &sets, headers).await {
109                return ArcValidationResult {
110                    status: ArcResult::Fail {
111                        reason: format!("AS({}) validation failed: {}", i + 1, reason),
112                    },
113                    oldest_pass: Option::None,
114                };
115            }
116        }
117
118        // Step 7: Success
119        ArcValidationResult {
120            status: ArcResult::Pass,
121            oldest_pass: Some(oldest_pass),
122        }
123    }
124
125    /// Validate a single AMS using DKIM verification.
126    async fn validate_ams(
127        &self,
128        ams: &ArcMessageSignature,
129        headers: &[(&str, &str)],
130        body: &[u8],
131    ) -> Result<(), String> {
132        // Body hash
133        let normalized = normalize_line_endings(body);
134        let canonicalized = canonicalize_body(ams.body_canonicalization, &normalized);
135        let limited = apply_body_length_limit(&canonicalized, ams.body_length);
136        let computed_body_hash = compute_hash(ams.algorithm, limited);
137
138        if !bool::from(computed_body_hash.ct_eq(&ams.body_hash)) {
139            return Err("body hash mismatch".to_string());
140        }
141
142        // Header hash: select headers per h=, then append AMS header with b= stripped
143        // Filter out ALL ARC headers and the current AMS header from header selection
144        let non_arc_headers: Vec<(&str, &str)> = headers
145            .iter()
146            .filter(|(name, _)| {
147                let lower = name.to_ascii_lowercase();
148                lower != "arc-authentication-results"
149                    && lower != "arc-message-signature"
150                    && lower != "arc-seal"
151            })
152            .copied()
153            .collect();
154
155        let selected = select_headers(
156            ams.header_canonicalization,
157            &ams.signed_headers,
158            &non_arc_headers,
159        );
160
161        let mut hash_input = Vec::new();
162        for header_line in &selected {
163            hash_input.extend_from_slice(header_line.as_bytes());
164        }
165
166        // Append canonicalized AMS header with b= stripped, NO trailing CRLF
167        let stripped = strip_b_tag_value(&ams.raw_header);
168        let canon_ams = canonicalize_header(
169            ams.header_canonicalization,
170            "arc-message-signature",
171            &stripped,
172        );
173        hash_input.extend_from_slice(canon_ams.as_bytes());
174
175        // DNS key lookup
176        let key = self.lookup_key(&ams.selector, &ams.domain).await?;
177
178        // Crypto verification
179        verify_signature(&ams.algorithm, &key, &hash_input, &ams.signature)
180    }
181
182    /// Validate a single ARC-Seal.
183    async fn validate_seal(
184        &self,
185        seal: &ArcSeal,
186        sets: &[ArcSet],
187        _headers: &[(&str, &str)],
188    ) -> Result<(), String> {
189        let instance = seal.instance as usize;
190
191        // Build signature input: all ARC Sets from 1 to instance
192        let mut hash_input = Vec::new();
193
194        for set_idx in 0..instance {
195            let set = &sets[set_idx];
196
197            // AAR
198            let aar_canon = canonicalize_header(
199                CanonicalizationMethod::Relaxed,
200                "arc-authentication-results",
201                &set.aar.raw_header,
202            );
203            hash_input.extend_from_slice(aar_canon.as_bytes());
204
205            // AMS
206            let ams_canon = canonicalize_header(
207                CanonicalizationMethod::Relaxed,
208                "arc-message-signature",
209                &set.ams.raw_header,
210            );
211            hash_input.extend_from_slice(ams_canon.as_bytes());
212
213            // AS — strip b= from the AS being validated (last one), keep b= for others
214            if set_idx == instance - 1 {
215                // This is the AS being validated — strip b= and NO trailing CRLF
216                let stripped = strip_b_tag_value(&set.seal.raw_header);
217                let canon_seal = canonicalize_header(
218                    CanonicalizationMethod::Relaxed,
219                    "arc-seal",
220                    &stripped,
221                );
222                // Remove trailing CRLF from the last header
223                let seal_bytes = canon_seal.as_bytes();
224                if seal_bytes.ends_with(b"\r\n") {
225                    hash_input.extend_from_slice(&seal_bytes[..seal_bytes.len() - 2]);
226                } else {
227                    hash_input.extend_from_slice(seal_bytes);
228                }
229            } else {
230                let seal_canon = canonicalize_header(
231                    CanonicalizationMethod::Relaxed,
232                    "arc-seal",
233                    &set.seal.raw_header,
234                );
235                hash_input.extend_from_slice(seal_canon.as_bytes());
236            }
237        }
238
239        // DNS key lookup
240        let key = self.lookup_key(&seal.selector, &seal.domain).await?;
241
242        // Crypto verification
243        verify_signature(&seal.algorithm, &key, &hash_input, &seal.signature)
244    }
245
246    /// DNS key lookup for ARC (same as DKIM).
247    async fn lookup_key(&self, selector: &str, domain: &str) -> Result<DkimPublicKey, String> {
248        let query = format!("{}._domainkey.{}", selector, domain);
249        let txt_records = match self.resolver.query_txt(&query).await {
250            Ok(records) => records,
251            Err(DnsError::NxDomain) | Err(DnsError::NoRecords) => {
252                return Err(format!("no DNS key record at {}", query));
253            }
254            Err(DnsError::TempFail) => {
255                return Err(format!("DNS temp failure for {}", query));
256            }
257        };
258
259        let concatenated = txt_records.join("");
260        DkimPublicKey::parse(&concatenated).map_err(|e| e.detail)
261    }
262}
263
264/// Validate structural integrity of ARC Sets.
265fn validate_structure(sets: &[ArcSet]) -> Result<(), String> {
266    for (idx, set) in sets.iter().enumerate() {
267        let expected_instance = (idx + 1) as u32;
268        if set.instance != expected_instance {
269            return Err(format!(
270                "expected instance {}, got {}",
271                expected_instance, set.instance
272            ));
273        }
274
275        // Instance 1: cv=none
276        if set.instance == 1 && set.seal.cv != ChainValidationStatus::None {
277            return Err(format!(
278                "instance 1 must have cv=none, got {:?}",
279                set.seal.cv
280            ));
281        }
282
283        // Instance >1: cv=pass
284        if set.instance > 1 && set.seal.cv != ChainValidationStatus::Pass {
285            return Err(format!(
286                "instance {} must have cv=pass, got {:?}",
287                set.instance, set.seal.cv
288            ));
289        }
290    }
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::arc::types::ArcAuthenticationResults;
298    use crate::common::dns::mock::MockResolver;
299    use crate::dkim::types::Algorithm;
300    use base64::Engine;
301    use ring::rand::SystemRandom;
302    use ring::signature::{Ed25519KeyPair, KeyPair};
303
304    fn gen_ed25519_keypair() -> (Vec<u8>, Vec<u8>) {
305        let rng = SystemRandom::new();
306        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
307        let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
308        let public_key = key_pair.public_key().as_ref().to_vec();
309        (pkcs8.as_ref().to_vec(), public_key)
310    }
311
312    fn ed25519_sign(pkcs8: &[u8], data: &[u8]) -> Vec<u8> {
313        let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8).unwrap();
314        key_pair.sign(data).as_ref().to_vec()
315    }
316
317    fn b64(data: &[u8]) -> String {
318        base64::engine::general_purpose::STANDARD.encode(data)
319    }
320
321    fn make_dns_key_record(public_key: &[u8]) -> String {
322        format!("v=DKIM1; k=ed25519; p={}", b64(public_key))
323    }
324
325    /// Build a complete single-hop ARC set with real signatures.
326    /// Returns (headers, resolver) ready for validation.
327    fn build_single_arc_set(
328    ) -> (Vec<(String, String)>, MockResolver, Vec<u8>, Vec<u8>) {
329        let (pkcs8, pub_key) = gen_ed25519_keypair();
330
331        let body = b"Hello, world!\r\n";
332        let message_headers = vec![
333            ("From".to_string(), "sender@example.com".to_string()),
334            ("To".to_string(), "recipient@example.com".to_string()),
335            ("Subject".to_string(), "test".to_string()),
336        ];
337
338        // Compute body hash (relaxed canonicalization)
339        let normalized = normalize_line_endings(body);
340        let canonicalized = canonicalize_body(CanonicalizationMethod::Relaxed, &normalized);
341        let body_hash = compute_hash(Algorithm::Ed25519Sha256, &canonicalized);
342
343        // Build AMS header (without b= value first)
344        let ams_raw_no_b = format!(
345            "i=1; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:to:subject; bh={}; b=",
346            b64(&body_hash)
347        );
348
349        // Compute AMS signature
350        let non_arc_headers: Vec<(&str, &str)> = message_headers
351            .iter()
352            .map(|(n, v)| (n.as_str(), v.as_str()))
353            .collect();
354
355        let selected = select_headers(
356            CanonicalizationMethod::Relaxed,
357            &["from".to_string(), "to".to_string(), "subject".to_string()],
358            &non_arc_headers,
359        );
360        let mut ams_hash_input = Vec::new();
361        for h in &selected {
362            ams_hash_input.extend_from_slice(h.as_bytes());
363        }
364        let canon_ams = canonicalize_header(
365            CanonicalizationMethod::Relaxed,
366            "arc-message-signature",
367            &ams_raw_no_b,
368        );
369        ams_hash_input.extend_from_slice(canon_ams.as_bytes());
370
371        let ams_sig = ed25519_sign(&pkcs8, &ams_hash_input);
372        let ams_raw = format!(
373            "i=1; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:to:subject; bh={}; b={}",
374            b64(&body_hash),
375            b64(&ams_sig),
376        );
377
378        // Build AAR
379        let aar_raw = "i=1; spf=pass smtp.mailfrom=example.com".to_string();
380
381        // Build AS (seal)
382        let seal_raw_no_b =
383            "i=1; cv=none; a=ed25519-sha256; d=sealer.com; s=arc; b=".to_string();
384
385        // AS signature input: AAR → AMS → AS(b= stripped, no trailing CRLF)
386        let mut seal_hash_input = Vec::new();
387        let canon_aar = canonicalize_header(
388            CanonicalizationMethod::Relaxed,
389            "arc-authentication-results",
390            &aar_raw,
391        );
392        seal_hash_input.extend_from_slice(canon_aar.as_bytes());
393        let canon_ams_full = canonicalize_header(
394            CanonicalizationMethod::Relaxed,
395            "arc-message-signature",
396            &ams_raw,
397        );
398        seal_hash_input.extend_from_slice(canon_ams_full.as_bytes());
399        let canon_seal = canonicalize_header(
400            CanonicalizationMethod::Relaxed,
401            "arc-seal",
402            &seal_raw_no_b,
403        );
404        // Remove trailing CRLF
405        let seal_bytes = canon_seal.as_bytes();
406        if seal_bytes.ends_with(b"\r\n") {
407            seal_hash_input.extend_from_slice(&seal_bytes[..seal_bytes.len() - 2]);
408        } else {
409            seal_hash_input.extend_from_slice(seal_bytes);
410        }
411
412        let seal_sig = ed25519_sign(&pkcs8, &seal_hash_input);
413        let seal_raw = format!(
414            "i=1; cv=none; a=ed25519-sha256; d=sealer.com; s=arc; b={}",
415            b64(&seal_sig),
416        );
417
418        // Build complete headers (ARC headers + message headers)
419        let mut all_headers = vec![
420            ("ARC-Seal".to_string(), seal_raw),
421            ("ARC-Message-Signature".to_string(), ams_raw),
422            ("ARC-Authentication-Results".to_string(), aar_raw),
423        ];
424        all_headers.extend(message_headers);
425
426        // Set up DNS
427        let mut resolver = MockResolver::new();
428        resolver.add_txt(
429            "arc._domainkey.sealer.com",
430            vec![make_dns_key_record(&pub_key)],
431        );
432
433        (all_headers, resolver, body.to_vec(), pkcs8)
434    }
435
436    // ─── CHK-825: No ARC headers → None ──────────────────────────────
437
438    #[tokio::test]
439    async fn no_arc_headers_none() {
440        let resolver = MockResolver::new();
441        let verifier = ArcVerifier::new(resolver);
442        let headers = vec![("From", "test@example.com")];
443        let result = verifier.validate_chain(&headers, b"body").await;
444        assert_eq!(result.status, ArcResult::None);
445    }
446
447    // ─── CHK-828: Highest cv=fail → Fail immediately ─────────────────
448
449    #[tokio::test]
450    async fn latest_cv_fail_immediately() {
451        let headers = vec![
452            ("ARC-Authentication-Results", "i=1; spf=pass"),
453            (
454                "ARC-Message-Signature",
455                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
456            ),
457            (
458                "ARC-Seal",
459                "i=1; cv=fail; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
460            ),
461        ];
462        let resolver = MockResolver::new();
463        let verifier = ArcVerifier::new(resolver);
464        let result = verifier.validate_chain(&headers, b"body").await;
465        assert!(matches!(result.status, ArcResult::Fail { .. }));
466    }
467
468    // ─── CHK-831: Instance 1 must have cv=none ───────────────────────
469
470    #[tokio::test]
471    async fn instance_1_cv_pass_fails() {
472        let headers = vec![
473            ("ARC-Authentication-Results", "i=1; spf=pass"),
474            (
475                "ARC-Message-Signature",
476                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
477            ),
478            (
479                "ARC-Seal",
480                "i=1; cv=pass; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
481            ),
482        ];
483        let resolver = MockResolver::new();
484        let verifier = ArcVerifier::new(resolver);
485        let result = verifier.validate_chain(&headers, b"body").await;
486        assert!(matches!(result.status, ArcResult::Fail { .. }));
487    }
488
489    // ─── CHK-832: Instance >1 must have cv=pass ──────────────────────
490
491    #[tokio::test]
492    async fn instance_2_cv_none_fails() {
493        // This will fail at structure validation because instance 2 has cv=none
494        let headers = vec![
495            ("ARC-Authentication-Results", "i=1; spf=pass"),
496            (
497                "ARC-Message-Signature",
498                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
499            ),
500            (
501                "ARC-Seal",
502                "i=1; cv=none; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
503            ),
504            ("ARC-Authentication-Results", "i=2; dkim=pass"),
505            (
506                "ARC-Message-Signature",
507                "i=2; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
508            ),
509            (
510                "ARC-Seal",
511                "i=2; cv=none; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
512            ),
513        ];
514        let resolver = MockResolver::new();
515        let verifier = ArcVerifier::new(resolver);
516        let result = verifier.validate_chain(&headers, b"body").await;
517        assert!(matches!(result.status, ArcResult::Fail { .. }));
518    }
519
520    // ─── CHK-882: Single ARC Set → Pass (with real crypto) ───────────
521
522    #[tokio::test]
523    async fn single_arc_set_pass() {
524        let (headers_owned, resolver, body, _) = build_single_arc_set();
525        let headers: Vec<(&str, &str)> = headers_owned
526            .iter()
527            .map(|(n, v)| (n.as_str(), v.as_str()))
528            .collect();
529        let verifier = ArcVerifier::new(resolver);
530        let result = verifier.validate_chain(&headers, &body).await;
531        assert_eq!(result.status, ArcResult::Pass);
532        assert_eq!(result.oldest_pass, Some(0)); // RFC 8617 §5.2(5)(B): all pass → 0
533    }
534
535    // ─── CHK-884: Gap in instances → Fail ────────────────────────────
536
537    #[tokio::test]
538    async fn gap_in_instances_fails() {
539        // Create headers with gap (1, 3)
540        let headers = vec![
541            ("ARC-Authentication-Results", "i=1; spf=pass"),
542            (
543                "ARC-Message-Signature",
544                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
545            ),
546            (
547                "ARC-Seal",
548                "i=1; cv=none; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
549            ),
550            ("ARC-Authentication-Results", "i=3; spf=pass"),
551            (
552                "ARC-Message-Signature",
553                "i=3; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
554            ),
555            (
556                "ARC-Seal",
557                "i=3; cv=pass; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
558            ),
559        ];
560        let resolver = MockResolver::new();
561        let verifier = ArcVerifier::new(resolver);
562        let result = verifier.validate_chain(&headers, b"body").await;
563        assert!(matches!(result.status, ArcResult::Fail { .. }));
564    }
565
566    // ─── CHK-885: Duplicate instances → Fail ─────────────────────────
567
568    #[tokio::test]
569    async fn duplicate_instances_fails() {
570        let headers = vec![
571            ("ARC-Authentication-Results", "i=1; spf=pass"),
572            ("ARC-Authentication-Results", "i=1; dkim=pass"),
573            (
574                "ARC-Message-Signature",
575                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
576            ),
577            (
578                "ARC-Seal",
579                "i=1; cv=none; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
580            ),
581        ];
582        let resolver = MockResolver::new();
583        let verifier = ArcVerifier::new(resolver);
584        let result = verifier.validate_chain(&headers, b"body").await;
585        assert!(matches!(result.status, ArcResult::Fail { .. }));
586    }
587
588    // ─── CHK-886: Instance 1 with cv=pass → Fail ─────────────────────
589
590    #[tokio::test]
591    async fn instance_1_cv_pass_structure_fail() {
592        let headers = vec![
593            ("ARC-Authentication-Results", "i=1; spf=pass"),
594            (
595                "ARC-Message-Signature",
596                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
597            ),
598            (
599                "ARC-Seal",
600                "i=1; cv=pass; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
601            ),
602        ];
603        let resolver = MockResolver::new();
604        let verifier = ArcVerifier::new(resolver);
605        let result = verifier.validate_chain(&headers, b"body").await;
606        assert!(matches!(result.status, ArcResult::Fail { .. }));
607    }
608
609    // ─── CHK-890: Most recent AMS body hash fails → Fail ─────────────
610
611    #[tokio::test]
612    async fn ams_body_hash_mismatch_fails() {
613        let (headers_owned, resolver, _, _) = build_single_arc_set();
614        let headers: Vec<(&str, &str)> = headers_owned
615            .iter()
616            .map(|(n, v)| (n.as_str(), v.as_str()))
617            .collect();
618        let verifier = ArcVerifier::new(resolver);
619        // Different body → body hash mismatch
620        let result = verifier.validate_chain(&headers, b"tampered body\r\n").await;
621        assert!(matches!(result.status, ArcResult::Fail { .. }));
622    }
623
624    // ─── CHK-891: AS crypto fail → Fail ──────────────────────────────
625
626    #[tokio::test]
627    async fn seal_tampered_fails() {
628        let (mut headers_owned, resolver, body, _) = build_single_arc_set();
629        // Tamper the AAR payload to break the AS signature
630        if let Some(aar) = headers_owned
631            .iter_mut()
632            .find(|(n, _)| n == "ARC-Authentication-Results")
633        {
634            aar.1 = "i=1; spf=fail smtp.mailfrom=evil.com".to_string();
635        }
636        let headers: Vec<(&str, &str)> = headers_owned
637            .iter()
638            .map(|(n, v)| (n.as_str(), v.as_str()))
639            .collect();
640        let verifier = ArcVerifier::new(resolver);
641        let result = verifier.validate_chain(&headers, &body).await;
642        assert!(matches!(result.status, ArcResult::Fail { .. }));
643    }
644
645    // ─── CHK-889: >50 sets → Fail ───────────────────────────────────
646
647    #[tokio::test]
648    async fn too_many_sets_fails() {
649        // Just check the instance=51 parse failure
650        let headers = vec![
651            ("ARC-Authentication-Results", "i=51; spf=pass"),
652        ];
653        let resolver = MockResolver::new();
654        let verifier = ArcVerifier::new(resolver);
655        let result = verifier.validate_chain(&headers, b"body").await;
656        assert!(matches!(result.status, ArcResult::Fail { .. }));
657    }
658
659    // ─── CHK-883: Three sets → Pass (multi-hop) ─────────────────────
660
661    #[tokio::test]
662    async fn three_sets_pass() {
663        // Build 3-hop ARC chain with real signatures
664        let (pkcs8, pub_key) = gen_ed25519_keypair();
665        let body = b"test body\r\n";
666
667        let mut resolver = MockResolver::new();
668        resolver.add_txt(
669            "arc._domainkey.sealer.com",
670            vec![make_dns_key_record(&pub_key)],
671        );
672
673        let message_headers: Vec<(String, String)> = vec![
674            ("From".to_string(), "s@example.com".to_string()),
675            ("Subject".to_string(), "test".to_string()),
676        ];
677
678        // Build chain iteratively
679        // ordered_sets stores (aar_raw, ams_raw, seal_raw) in instance order for seal construction
680        let mut ordered_sets: Vec<(String, String, String)> = Vec::new();
681
682        for hop in 1..=3u32 {
683            let cv = if hop == 1 { "none" } else { "pass" };
684
685            // AMS
686            let normalized = normalize_line_endings(body);
687            let canonicalized =
688                canonicalize_body(CanonicalizationMethod::Relaxed, &normalized);
689            let body_hash = compute_hash(Algorithm::Ed25519Sha256, &canonicalized);
690
691            let ams_raw_no_b = format!(
692                "i={}; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:subject; bh={}; b=",
693                hop,
694                b64(&body_hash),
695            );
696
697            let non_arc: Vec<(&str, &str)> = message_headers
698                .iter()
699                .map(|(n, v)| (n.as_str(), v.as_str()))
700                .collect();
701
702            let selected = select_headers(
703                CanonicalizationMethod::Relaxed,
704                &["from".to_string(), "subject".to_string()],
705                &non_arc,
706            );
707            let mut ams_input = Vec::new();
708            for h in &selected {
709                ams_input.extend_from_slice(h.as_bytes());
710            }
711            let canon_ams =
712                canonicalize_header(CanonicalizationMethod::Relaxed, "arc-message-signature", &ams_raw_no_b);
713            ams_input.extend_from_slice(canon_ams.as_bytes());
714            let ams_sig = ed25519_sign(&pkcs8, &ams_input);
715            let ams_raw = format!(
716                "i={}; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:subject; bh={}; b={}",
717                hop, b64(&body_hash), b64(&ams_sig),
718            );
719
720            let aar_raw = format!("i={}; spf=pass", hop);
721
722            let seal_raw_no_b = format!(
723                "i={}; cv={}; a=ed25519-sha256; d=sealer.com; s=arc; b=",
724                hop, cv,
725            );
726
727            // AS signature input: all ARC sets 1..hop
728            let mut seal_input = Vec::new();
729            // Previous sets (from ordered_sets, stable indices)
730            for prev in &ordered_sets {
731                let c = canonicalize_header(
732                    CanonicalizationMethod::Relaxed,
733                    "arc-authentication-results",
734                    &prev.0,
735                );
736                seal_input.extend_from_slice(c.as_bytes());
737                let c = canonicalize_header(
738                    CanonicalizationMethod::Relaxed,
739                    "arc-message-signature",
740                    &prev.1,
741                );
742                seal_input.extend_from_slice(c.as_bytes());
743                let c = canonicalize_header(
744                    CanonicalizationMethod::Relaxed,
745                    "arc-seal",
746                    &prev.2,
747                );
748                seal_input.extend_from_slice(c.as_bytes());
749            }
750            // Current set: AAR, AMS, AS(b= stripped, no trailing CRLF)
751            let c = canonicalize_header(
752                CanonicalizationMethod::Relaxed,
753                "arc-authentication-results",
754                &aar_raw,
755            );
756            seal_input.extend_from_slice(c.as_bytes());
757            let c = canonicalize_header(
758                CanonicalizationMethod::Relaxed,
759                "arc-message-signature",
760                &ams_raw,
761            );
762            seal_input.extend_from_slice(c.as_bytes());
763            let c = canonicalize_header(
764                CanonicalizationMethod::Relaxed,
765                "arc-seal",
766                &seal_raw_no_b,
767            );
768            let seal_bytes = c.as_bytes();
769            if seal_bytes.ends_with(b"\r\n") {
770                seal_input.extend_from_slice(&seal_bytes[..seal_bytes.len() - 2]);
771            } else {
772                seal_input.extend_from_slice(seal_bytes);
773            }
774
775            let seal_sig = ed25519_sign(&pkcs8, &seal_input);
776            let seal_raw = format!(
777                "i={}; cv={}; a=ed25519-sha256; d=sealer.com; s=arc; b={}",
778                hop, cv, b64(&seal_sig),
779            );
780
781            // Store in instance order for seal construction
782            ordered_sets.push((aar_raw.clone(), ams_raw.clone(), seal_raw.clone()));
783        }
784
785        // Build headers in email order (newest first)
786        let mut arc_headers: Vec<(String, String)> = Vec::new();
787        for (aar, ams, seal) in ordered_sets.iter().rev() {
788            arc_headers.push(("ARC-Seal".to_string(), seal.clone()));
789            arc_headers.push(("ARC-Message-Signature".to_string(), ams.clone()));
790            arc_headers.push(("ARC-Authentication-Results".to_string(), aar.clone()));
791        }
792
793        // Combine headers
794        let mut all_headers: Vec<(String, String)> = arc_headers;
795        all_headers.extend(message_headers);
796
797        let headers: Vec<(&str, &str)> = all_headers
798            .iter()
799            .map(|(n, v)| (n.as_str(), v.as_str()))
800            .collect();
801        let verifier = ArcVerifier::new(resolver);
802        let result = verifier.validate_chain(&headers, body).await;
803        assert_eq!(result.status, ArcResult::Pass);
804        assert_eq!(result.oldest_pass, Some(0)); // RFC 8617 §5.2(5)(B): all pass → 0
805    }
806
807    // ─── CHK-887: Instance 2 cv=none → Fail ─────────────────────────
808
809    #[tokio::test]
810    async fn instance_2_cv_none_structure_fail() {
811        let headers = vec![
812            ("ARC-Authentication-Results", "i=1; spf=pass"),
813            (
814                "ARC-Message-Signature",
815                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
816            ),
817            (
818                "ARC-Seal",
819                "i=1; cv=none; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
820            ),
821            ("ARC-Authentication-Results", "i=2; dkim=pass"),
822            (
823                "ARC-Message-Signature",
824                "i=2; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
825            ),
826            (
827                "ARC-Seal",
828                "i=2; cv=none; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
829            ),
830        ];
831        let resolver = MockResolver::new();
832        let verifier = ArcVerifier::new(resolver);
833        let result = verifier.validate_chain(&headers, b"body").await;
834        assert!(matches!(result.status, ArcResult::Fail { .. }));
835    }
836
837    // ─── CHK-888: Highest cv=fail → immediate Fail ───────────────────
838
839    #[tokio::test]
840    async fn highest_cv_fail_fast() {
841        let headers = vec![
842            ("ARC-Authentication-Results", "i=1; spf=pass"),
843            (
844                "ARC-Message-Signature",
845                "i=1; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==; bh=dGVzdA==; h=from",
846            ),
847            (
848                "ARC-Seal",
849                "i=1; cv=fail; a=rsa-sha256; d=ex.com; s=s1; b=dGVzdA==",
850            ),
851        ];
852        let resolver = MockResolver::new();
853        let verifier = ArcVerifier::new(resolver);
854        let result = verifier.validate_chain(&headers, b"body").await;
855        assert!(matches!(result.status, ArcResult::Fail { .. }));
856    }
857
858    // ─── CHK-901: Body modification → oldest_pass > 0 ─────────────
859
860    #[tokio::test]
861    async fn oldest_pass_after_body_modification() {
862        // Construct 2-hop chain where AMS(1) was signed over original body
863        // and AMS(2) over modified body. Both AS signatures valid.
864        // Validator should return Pass with oldest_pass = 2.
865        let (pkcs8, pub_key) = gen_ed25519_keypair();
866
867        let original_body = b"original body\r\n";
868        let modified_body = b"modified body\r\n";
869        let message_headers: Vec<(String, String)> = vec![
870            ("From".to_string(), "s@example.com".to_string()),
871            ("Subject".to_string(), "test".to_string()),
872        ];
873
874        // ─── Hop 1: AMS signed over original body ───
875        let normalized_orig = normalize_line_endings(original_body);
876        let canon_orig = canonicalize_body(CanonicalizationMethod::Relaxed, &normalized_orig);
877        let bh_orig = compute_hash(Algorithm::Ed25519Sha256, &canon_orig);
878
879        let ams1_raw_no_b = format!(
880            "i=1; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:subject; bh={}; b=",
881            b64(&bh_orig),
882        );
883
884        let non_arc: Vec<(&str, &str)> = message_headers
885            .iter()
886            .map(|(n, v)| (n.as_str(), v.as_str()))
887            .collect();
888        let selected = select_headers(
889            CanonicalizationMethod::Relaxed,
890            &["from".to_string(), "subject".to_string()],
891            &non_arc,
892        );
893        let mut ams1_input = Vec::new();
894        for h in &selected {
895            ams1_input.extend_from_slice(h.as_bytes());
896        }
897        let canon_ams1 = canonicalize_header(
898            CanonicalizationMethod::Relaxed,
899            "arc-message-signature",
900            &ams1_raw_no_b,
901        );
902        ams1_input.extend_from_slice(canon_ams1.as_bytes());
903        let ams1_sig = ed25519_sign(&pkcs8, &ams1_input);
904        let ams1_raw = format!(
905            "i=1; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:subject; bh={}; b={}",
906            b64(&bh_orig), b64(&ams1_sig),
907        );
908
909        let aar1_raw = "i=1; spf=pass".to_string();
910
911        // AS(1): cv=none
912        let seal1_raw_no_b = "i=1; cv=none; a=ed25519-sha256; d=sealer.com; s=arc; b=".to_string();
913        let mut seal1_input = Vec::new();
914        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-authentication-results", &aar1_raw);
915        seal1_input.extend_from_slice(c.as_bytes());
916        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-message-signature", &ams1_raw);
917        seal1_input.extend_from_slice(c.as_bytes());
918        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-seal", &seal1_raw_no_b);
919        let sb = c.as_bytes();
920        if sb.ends_with(b"\r\n") {
921            seal1_input.extend_from_slice(&sb[..sb.len() - 2]);
922        } else {
923            seal1_input.extend_from_slice(sb);
924        }
925        let seal1_sig = ed25519_sign(&pkcs8, &seal1_input);
926        let seal1_raw = format!(
927            "i=1; cv=none; a=ed25519-sha256; d=sealer.com; s=arc; b={}",
928            b64(&seal1_sig),
929        );
930
931        // ─── Hop 2: AMS signed over modified body ───
932        let normalized_mod = normalize_line_endings(modified_body);
933        let canon_mod = canonicalize_body(CanonicalizationMethod::Relaxed, &normalized_mod);
934        let bh_mod = compute_hash(Algorithm::Ed25519Sha256, &canon_mod);
935
936        let ams2_raw_no_b = format!(
937            "i=2; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:subject; bh={}; b=",
938            b64(&bh_mod),
939        );
940        let mut ams2_input = Vec::new();
941        for h in &selected {
942            ams2_input.extend_from_slice(h.as_bytes());
943        }
944        let canon_ams2 = canonicalize_header(
945            CanonicalizationMethod::Relaxed,
946            "arc-message-signature",
947            &ams2_raw_no_b,
948        );
949        ams2_input.extend_from_slice(canon_ams2.as_bytes());
950        let ams2_sig = ed25519_sign(&pkcs8, &ams2_input);
951        let ams2_raw = format!(
952            "i=2; a=ed25519-sha256; d=sealer.com; s=arc; c=relaxed/relaxed; h=from:subject; bh={}; b={}",
953            b64(&bh_mod), b64(&ams2_sig),
954        );
955
956        let aar2_raw = "i=2; arc=pass".to_string();
957
958        // AS(2): cv=pass, covers sets 1..2
959        let seal2_raw_no_b = "i=2; cv=pass; a=ed25519-sha256; d=sealer.com; s=arc; b=".to_string();
960        let mut seal2_input = Vec::new();
961        // Set 1
962        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-authentication-results", &aar1_raw);
963        seal2_input.extend_from_slice(c.as_bytes());
964        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-message-signature", &ams1_raw);
965        seal2_input.extend_from_slice(c.as_bytes());
966        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-seal", &seal1_raw);
967        seal2_input.extend_from_slice(c.as_bytes());
968        // Set 2
969        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-authentication-results", &aar2_raw);
970        seal2_input.extend_from_slice(c.as_bytes());
971        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-message-signature", &ams2_raw);
972        seal2_input.extend_from_slice(c.as_bytes());
973        let c = canonicalize_header(CanonicalizationMethod::Relaxed, "arc-seal", &seal2_raw_no_b);
974        let sb = c.as_bytes();
975        if sb.ends_with(b"\r\n") {
976            seal2_input.extend_from_slice(&sb[..sb.len() - 2]);
977        } else {
978            seal2_input.extend_from_slice(sb);
979        }
980        let seal2_sig = ed25519_sign(&pkcs8, &seal2_input);
981        let seal2_raw = format!(
982            "i=2; cv=pass; a=ed25519-sha256; d=sealer.com; s=arc; b={}",
983            b64(&seal2_sig),
984        );
985
986        // Build headers: newest first
987        let mut all_headers: Vec<(String, String)> = vec![
988            ("ARC-Seal".to_string(), seal2_raw),
989            ("ARC-Message-Signature".to_string(), ams2_raw),
990            ("ARC-Authentication-Results".to_string(), aar2_raw),
991            ("ARC-Seal".to_string(), seal1_raw),
992            ("ARC-Message-Signature".to_string(), ams1_raw),
993            ("ARC-Authentication-Results".to_string(), aar1_raw),
994        ];
995        all_headers.extend(message_headers);
996
997        let headers: Vec<(&str, &str)> = all_headers
998            .iter()
999            .map(|(n, v)| (n.as_str(), v.as_str()))
1000            .collect();
1001
1002        let mut resolver = MockResolver::new();
1003        resolver.add_txt(
1004            "arc._domainkey.sealer.com",
1005            vec![make_dns_key_record(&pub_key)],
1006        );
1007        let verifier = ArcVerifier::new(resolver);
1008
1009        // Validate with modified body: AMS(2) passes, AMS(1) body hash fails
1010        let result = verifier.validate_chain(&headers, modified_body).await;
1011        assert_eq!(result.status, ArcResult::Pass);
1012        // oldest_pass should be 2 (AMS(1) at index 0 failed, so oldest passing is instance 2)
1013        assert_eq!(result.oldest_pass, Some(2));
1014    }
1015
1016    // ─── Structure validation unit test ──────────────────────────────
1017
1018    #[test]
1019    fn validate_structure_valid() {
1020        let sets = vec![
1021            ArcSet {
1022                instance: 1,
1023                aar: ArcAuthenticationResults {
1024                    instance: 1,
1025                    payload: "".to_string(),
1026                    raw_header: "".to_string(),
1027                },
1028                ams: ArcMessageSignature {
1029                    instance: 1,
1030                    algorithm: Algorithm::RsaSha256,
1031                    signature: vec![],
1032                    body_hash: vec![],
1033                    domain: "".to_string(),
1034                    selector: "".to_string(),
1035                    signed_headers: vec![],
1036                    header_canonicalization: CanonicalizationMethod::Relaxed,
1037                    body_canonicalization: CanonicalizationMethod::Relaxed,
1038                    timestamp: Option::None,
1039                    body_length: Option::None,
1040                    raw_header: "".to_string(),
1041                },
1042                seal: ArcSeal {
1043                    instance: 1,
1044                    cv: ChainValidationStatus::None,
1045                    algorithm: Algorithm::RsaSha256,
1046                    signature: vec![],
1047                    domain: "".to_string(),
1048                    selector: "".to_string(),
1049                    timestamp: Option::None,
1050                    raw_header: "".to_string(),
1051                },
1052            },
1053        ];
1054        assert!(validate_structure(&sets).is_ok());
1055    }
1056}