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