revenant-sign-core 3.0.5

Cross-platform client library for ARX CoSign / DocuSign Signature Appliance electronic signatures via the OASIS DSS SOAP API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
//! Post-sign verification of embedded and detached PDF signatures.
//!
//! Extracts the ByteRange data and CMS blob, checks structural validity, and
//! verifies the ByteRange hash against the algorithm and `messageDigest`
//! declared in the CMS -- plus, on the post-sign path, the exact hash that was
//! sent for signing. The two are checked together, never one instead of the
//! other.
//!
//! Certificate-chain validation needs a network transport (to fetch the Trust
//! Service List and intermediates), so it is *injected* as a closure rather than
//! called directly -- verification itself stays offline and pure. The signing
//! layer supplies a validator backed by a [`crate::net::Transport`]; passing
//! `None` skips the chain step (`trust_status` stays `Indeterminate`).

use super::reader::PdfReader;
use crate::cms::{
    check_ltv_status, extract_digest_info, extract_signature_data_for, extract_signer_info,
    find_byteranges, has_signed_attributes, verify_signer_signature, ByteRange, ByteRangeCoverage,
    DigestAlgorithm, SignatureStatus, ASN1_SEQUENCE_TAG, MIN_CMS_SIZE,
};
use crate::pki::{CertInfo, ChainResult, TrustStatus};
use crate::{Result, RevenantError};

/// A caller-supplied certificate-chain validator: given the CMS DER, it returns
/// a [`ChainResult`], or `None` when validation is unavailable.
pub type ChainValidator<'a> = dyn Fn(&[u8]) -> Option<ChainResult> + 'a;

/// The result of verifying a single signature.
#[derive(Debug, Clone)]
pub struct VerificationResult {
    /// The ByteRange and CMS structure are well-formed.
    pub structure_ok: bool,
    /// The ByteRange hash matches the expected/declared value.
    pub hash_ok: bool,
    /// Whether the signer's cryptographic signature over the CMS signed
    /// attributes verifies against the signer certificate.
    pub signature: SignatureStatus,
    /// How much of the file this signature covers.
    pub coverage: ByteRangeCoverage,
    /// The CMS embeds long-term-validation revocation data.
    pub ltv_enabled: bool,
    /// Human-readable diagnostic lines.
    pub details: Vec<String>,
    /// Signer identity from the CMS certificate, if extractable.
    pub signer: Option<CertInfo>,
    /// The matched trust-anchor service name, if any.
    pub trust_anchor: Option<String>,
    /// The chain trust verdict, or `None` when the structure could not even be
    /// extracted (so chain validation was never attempted).
    pub trust_status: Option<TrustStatus>,
}

impl VerificationResult {
    /// Structural integrity: the CMS parses and the ByteRange hash matches both
    /// the expected value (when one is supplied) and the digest the CMS itself
    /// declares. This proves the signed bytes are intact but NOT that the named
    /// signer produced the signature -- use [`valid`] for the full
    /// cryptographic verdict. Reported on its own so a diagnostic view can say
    /// *which* half failed; no workflow accepts a document on this alone.
    ///
    /// [`valid`]: VerificationResult::valid
    #[must_use]
    pub fn integrity_ok(&self) -> bool {
        self.structure_ok && self.hash_ok
    }

    /// Full cryptographic validity: structurally sound, the hash matches, *and*
    /// the signer's signature verifies against its certificate. This is what a
    /// caller asking "is this a genuine signature?" wants. Trust in the signer's
    /// certificate chain is reported separately via [`trust_status`].
    ///
    /// [`trust_status`]: VerificationResult::trust_status
    #[must_use]
    pub fn valid(&self) -> bool {
        self.integrity_ok() && self.signature.is_valid()
    }

    /// The result for a signature whose structure could not even be extracted.
    fn structure_error(message: String) -> Self {
        Self {
            structure_ok: false,
            hash_ok: false,
            signature: SignatureStatus::Unverifiable("structure could not be extracted"),
            coverage: ByteRangeCoverage::default(),
            ltv_enabled: false,
            details: vec![message],
            signer: None,
            trust_anchor: None,
            trust_status: None,
        }
    }
}

/// Core verification for a single ByteRange.
fn verify_signature_match(
    pdf_bytes: &[u8],
    br: &ByteRange,
    expected_hash: Option<&[u8]>,
    validate_chain: Option<&ChainValidator<'_>>,
) -> VerificationResult {
    let mut details: Vec<String> = Vec::new();

    // 1. Extract the signed data and CMS blob.
    let (signed_data, cms_der) = match extract_signature_data_for(pdf_bytes, br) {
        Ok(pair) => pair,
        Err(e) => return VerificationResult::structure_error(format!("Structure error: {e}")),
    };
    details.push(format!(
        "ByteRange OK -- signed data: {} bytes",
        signed_data.len()
    ));
    details.push(format!("CMS blob: {} bytes", cms_der.len()));

    // 1a. Signature coverage. Bytes past the ByteRange are not signed by this
    //     signature. In an incrementally updated PDF they are usually a later
    //     revision -- possibly another signature, possibly an unsigned change.
    //     Reported, never inferred.
    let coverage = br.coverage(pdf_bytes);
    if coverage.covers_to_eof() {
        details.push(format!(
            "Coverage: whole file ({} of {} bytes)",
            coverage.covered_bytes, coverage.total_bytes
        ));
    } else {
        details.push(format!(
            "Coverage: partial -- {} of {} bytes follow this signature and are outside it",
            coverage.trailing_bytes(),
            coverage.total_bytes
        ));
    }

    // 2. CMS structure check.
    let structure_ok = check_cms_structure(&cms_der, &mut details);

    // 3. Signer info.
    let signer = extract_signer_info(&cms_der);
    if let Some(name) = signer.as_ref().and_then(|s| s.name.as_ref()) {
        details.push(format!("Signer: {name}"));
    }

    // 4. Cryptographic signature verification (the signer's key signed the
    //    signed attributes, or the content itself when there are none).
    let signature = verify_signer_signature(&cms_der, Some(&signed_data));

    // 5. Hash verification (messageDigest == hash of the signed bytes). Together
    //    with the signature check this proves the named signer signed exactly
    //    these bytes.
    let hash_ok = verify_hash(
        &signed_data,
        &cms_der,
        expected_hash,
        signature,
        &mut details,
    );
    details.push(signature.describe());

    // 6. LTV status.
    let ltv = check_ltv_status(&cms_der);
    let ltv_enabled = ltv.ltv_enabled();
    details.push(format!(
        "LTV: {}",
        if ltv_enabled {
            "LTV enabled"
        } else {
            "Not LTV enabled"
        }
    ));

    let mut result = VerificationResult {
        structure_ok,
        hash_ok,
        signature,
        coverage,
        ltv_enabled,
        details,
        signer,
        trust_anchor: None,
        trust_status: Some(TrustStatus::Indeterminate),
    };

    // 7. Chain validation (optional, injected, best-effort).
    apply_chain(&mut result, &cms_der, validate_chain);
    result
}

/// Check the CMS begins with an ASN.1 SEQUENCE and is not implausibly small.
fn check_cms_structure(cms_der: &[u8], details: &mut Vec<String>) -> bool {
    if cms_der.len() < MIN_CMS_SIZE {
        details.push(format!(
            "CMS too small ({} bytes) -- likely corrupt",
            cms_der.len()
        ));
        false
    } else if cms_der.first() != Some(&ASN1_SEQUENCE_TAG) {
        details.push("CMS does not start with ASN.1 SEQUENCE tag (0x30)".to_owned());
        false
    } else {
        details.push("CMS: valid ASN.1 structure".to_owned());
        true
    }
}

/// Verify the ByteRange hash against an optional caller-supplied oracle *and*
/// the digest the CMS itself declares.
///
/// Both must hold. Knowing the exact hash that was submitted for signing says
/// nothing about what the returned CMS ended up binding, so the post-sign path
/// checks the `messageDigest` too rather than treating the oracle as a
/// substitute for it.
fn verify_hash(
    signed_data: &[u8],
    cms_der: &[u8],
    expected_hash: Option<&[u8]>,
    signature: SignatureStatus,
    details: &mut Vec<String>,
) -> bool {
    let mut expected_ok = true;
    if let Some(expected) = expected_hash {
        let actual = DigestAlgorithm::Sha1.hash(signed_data);
        expected_ok = actual == expected;
        if expected_ok {
            details.push(format!(
                "Hash OK -- SHA-1 matches expected: {}",
                hex::encode(&actual)
            ));
        } else {
            details.push(format!(
                "Hash MISMATCH!\n  ByteRange SHA-1: {}\n  Expected:        {}",
                hex::encode(&actual),
                hex::encode(expected)
            ));
        }
    }

    if let Some((algo, cms_digest)) = extract_digest_info(cms_der) {
        let actual = algo.hash(signed_data);
        let algo_upper = algo.name().to_uppercase();
        if actual == cms_digest {
            details.push(format!(
                "Hash OK -- {algo_upper} matches CMS messageDigest: {}",
                hex::encode(&actual)
            ));
            return expected_ok;
        }
        details.push(format!(
            "Hash MISMATCH!\n  ByteRange {algo_upper}:   {}\n  CMS messageDigest:  {}",
            hex::encode(&actual),
            hex::encode(&cms_digest)
        ));
        return false;
    }

    if has_signed_attributes(cms_der) == Some(false) {
        // RFC 5652 section 5.4: with no signed attributes there is no separate
        // messageDigest to compare -- the signature itself binds these bytes,
        // so integrity follows from the signature verdict alone.
        details.push(
            "Integrity: signature covers the signed bytes directly (no signed attributes)"
                .to_owned(),
        );
        return expected_ok && signature.is_valid();
    }

    details.push("Could not extract CMS messageDigest -- hash verification unavailable".to_owned());
    false
}

/// Fold an injected chain-validation result into the verification result.
fn apply_chain(
    result: &mut VerificationResult,
    cms_der: &[u8],
    validate_chain: Option<&ChainValidator<'_>>,
) {
    let Some(validate) = validate_chain else {
        return;
    };
    if let Some(chain) = validate(cms_der) {
        result.trust_status = Some(chain.trust);
        result.trust_anchor = chain.trust_anchor;
        result.details.extend(chain.details);
    } else {
        result
            .details
            .push("Chain: validation unavailable".to_owned());
    }
}

/// Append a best-effort structural note from an independent PDF parser.
fn parser_note(pdf_bytes: &[u8]) -> String {
    match PdfReader::open(pdf_bytes) {
        Ok(reader) => format!("parser: valid PDF, {} page(s)", reader.page_count()),
        Err(e) => format!("parser: structural warning -- {e}"),
    }
}

/// Verify the last embedded signature in a PDF.
///
/// Never fails on a *verification* problem -- it returns a result with
/// `structure_ok`/`hash_ok` set and diagnostics in `details`. Multi-signature
/// PDFs: only the last (most recent) signature is checked; use
/// [`verify_all_embedded_signatures`] for every one.
#[must_use]
pub fn verify_embedded_signature(
    pdf_bytes: &[u8],
    expected_hash: Option<&[u8]>,
    validate_chain: Option<&ChainValidator<'_>>,
) -> VerificationResult {
    let ranges = match find_byteranges(pdf_bytes) {
        Ok(ranges) => ranges,
        Err(e) => return VerificationResult::structure_error(format!("Structure error: {e}")),
    };
    let Some(br) = ranges.last() else {
        return VerificationResult::structure_error(
            "Structure error: No /ByteRange found in PDF -- not a signed PDF?".to_owned(),
        );
    };

    let mut result = verify_signature_match(pdf_bytes, br, expected_hash, validate_chain);
    result.details.push(parser_note(pdf_bytes));
    result
}

/// Verify every embedded signature, in document order.
///
/// # Errors
///
/// Returns [`RevenantError::Pdf`] if the PDF has no embedded signatures.
pub fn verify_all_embedded_signatures(
    pdf_bytes: &[u8],
    validate_chain: Option<&ChainValidator<'_>>,
) -> Result<Vec<VerificationResult>> {
    let ranges = find_byteranges(pdf_bytes)?;
    if ranges.is_empty() {
        return Err(RevenantError::Pdf(
            "No /ByteRange found in PDF -- not a signed PDF?".to_owned(),
        ));
    }

    let note = parser_note(pdf_bytes);
    let mut results: Vec<VerificationResult> = ranges
        .iter()
        .map(|br| {
            let mut result = verify_signature_match(pdf_bytes, br, None, validate_chain);
            result.details.push(note.clone());
            result
        })
        .collect();

    // One signature covering an earlier revision is expected -- a later
    // signature covers the rest. Bytes past *every* signature are signed by
    // nobody, which is decided by arithmetic alone, without inspecting what
    // they contain.
    if !results.iter().any(|r| r.coverage.covers_to_eof()) {
        let furthest = ranges
            .iter()
            .map(|br| br.coverage(pdf_bytes).coverage_end)
            .max()
            .unwrap_or(0);
        let unsigned = pdf_bytes.len().saturating_sub(furthest);
        if let Some(last) = results.last_mut() {
            last.details.push(format!(
                "WARNING: {unsigned} trailing bytes are covered by no signature in this document"
            ));
        }
    }
    Ok(results)
}

/// Verify a detached CMS/PKCS#7 signature against the original data.
#[must_use]
pub fn verify_detached_signature(
    data_bytes: &[u8],
    cms_der: &[u8],
    validate_chain: Option<&ChainValidator<'_>>,
) -> VerificationResult {
    let mut details: Vec<String> = Vec::new();

    let structure_ok = if cms_der.len() < MIN_CMS_SIZE {
        details.push(format!(
            "CMS too small ({} bytes) -- likely corrupt",
            cms_der.len()
        ));
        false
    } else if cms_der.first() != Some(&ASN1_SEQUENCE_TAG) {
        details.push("CMS does not start with ASN.1 SEQUENCE tag (0x30)".to_owned());
        false
    } else {
        details.push(format!(
            "CMS blob: {} bytes, valid ASN.1 structure",
            cms_der.len()
        ));
        true
    };

    let signer = extract_signer_info(cms_der);
    if let Some(name) = signer.as_ref().and_then(|s| s.name.as_ref()) {
        details.push(format!("Signer: {name}"));
    }

    // Cryptographic signature verification (signer's key over the signed attrs,
    // or over the content itself when the CMS carries none).
    let signature = verify_signer_signature(cms_der, Some(data_bytes));
    details.push(signature.describe());

    // Detached signatures are always verified against the CMS-declared digest.
    let hash_ok = if let Some((algo, cms_digest)) = extract_digest_info(cms_der) {
        let actual = algo.hash(data_bytes);
        let algo_upper = algo.name().to_uppercase();
        if actual == cms_digest {
            details.push(format!(
                "Hash OK -- {algo_upper} matches CMS messageDigest: {}",
                hex::encode(&actual)
            ));
            true
        } else {
            details.push(format!(
                "Hash MISMATCH!\n  Data {algo_upper}:        {}\n  CMS messageDigest:  {}",
                hex::encode(&actual),
                hex::encode(&cms_digest)
            ));
            false
        }
    } else if has_signed_attributes(cms_der) == Some(false) {
        // RFC 5652 section 5.4: the signature covers the content directly.
        details.push(
            "Integrity: signature covers the signed bytes directly (no signed attributes)"
                .to_owned(),
        );
        signature.is_valid()
    } else {
        details.push("Could not extract digest info -- hash verification unavailable".to_owned());
        false
    };

    let ltv = check_ltv_status(cms_der);
    let ltv_enabled = ltv.ltv_enabled();
    details.push(format!(
        "LTV: {}",
        if ltv_enabled {
            "LTV enabled"
        } else {
            "Not LTV enabled"
        }
    ));

    let mut result = VerificationResult {
        structure_ok,
        hash_ok,
        signature,
        // A detached signature covers exactly the data it was handed; there is
        // no surrounding file that could carry unsigned bytes.
        coverage: ByteRangeCoverage::whole(data_bytes.len()),
        ltv_enabled,
        details,
        signer,
        trust_anchor: None,
        trust_status: Some(TrustStatus::Indeterminate),
    };
    apply_chain(&mut result, cms_der, validate_chain);
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pdf::{
        compute_byterange_hash, insert_cms, prepare_pdf_with_sig_field, PrepareOptions, PreparedPdf,
    };

    const BLANK_LETTER: &[u8] = include_bytes!("testdata/blank_letter.pdf");
    const CMS_LEAF: &[u8] = include_bytes!("../pki/testdata/cms_leaf_direct.der");

    /// Prepare + fake-sign a PDF, returning (signed_pdf, sha1_of_byterange).
    /// Prepare a PDF and splice in a genuine signature over its ByteRange,
    /// returning the signed PDF and the hash that was signed.
    fn prepare_and_sign() -> (Vec<u8>, [u8; 20]) {
        prepare_and_splice(crate::testutil::sign_cms_detached)
    }

    /// The same flow with filler bytes in place of a signature -- what a
    /// compromised or impersonated signing service could return.
    fn prepare_and_fake_sign() -> (Vec<u8>, [u8; 20]) {
        prepare_and_splice(|_| {
            let mut cms = vec![0x30, 0x81, 0xC8];
            cms.extend(std::iter::repeat_n(0xAB, 200));
            cms
        })
    }

    fn prepare_and_splice(make_cms: impl Fn(&[u8]) -> Vec<u8>) -> (Vec<u8>, [u8; 20]) {
        let opts = PrepareOptions {
            name: Some("Verify Test"),
            ..Default::default()
        };
        let PreparedPdf {
            bytes: prepared,
            contents_hex_offset: hex_start,
            contents_hex_len: hex_len,
        } = prepare_pdf_with_sig_field(BLANK_LETTER, &opts).unwrap();
        let hash = compute_byterange_hash(&prepared, hex_start, hex_len).unwrap();
        let mut byterange = Vec::with_capacity(prepared.len().saturating_sub(hex_len + 1));
        byterange.extend_from_slice(&prepared[..hex_start]);
        byterange.extend_from_slice(&prepared[hex_start + hex_len + 1..]);
        let cms = make_cms(&byterange);
        let signed = insert_cms(&prepared, hex_start, hex_len, &cms).unwrap();
        (signed, hash)
    }

    #[test]
    fn reports_coverage_and_warns_when_nothing_signs_the_trailing_bytes() {
        let (signed, _) = prepare_and_fake_sign();

        let whole = verify_embedded_signature(&signed, None, None);
        assert!(whole.coverage.covers_to_eof(), "{:?}", whole.details);
        // The /Contents slot sits inside the file but outside the ByteRange.
        assert!(whole.coverage.covered_bytes < whole.coverage.total_bytes);
        assert!(whole
            .details
            .iter()
            .any(|d| d.starts_with("Coverage: whole file")));

        let appended = b"\n% appended after the signature\n";
        let mut extended = signed.clone();
        extended.extend_from_slice(appended);

        let results = verify_all_embedded_signatures(&extended, None).unwrap();
        let last = results.last().expect("one signature");
        assert!(!last.coverage.covers_to_eof());
        assert!(last
            .details
            .iter()
            .any(|d| d.starts_with("Coverage: partial")));
        assert!(last.details.iter().any(|d| {
            d.starts_with("WARNING") && d.contains(&format!("{} trailing bytes", appended.len()))
        }));
    }

    #[test]
    fn verifies_with_expected_hash() {
        let (signed, hash) = prepare_and_sign();
        let result = verify_embedded_signature(&signed, Some(&hash), None);
        assert!(result.structure_ok, "{:?}", result.details);
        assert!(result.hash_ok, "{:?}", result.details);
        assert!(result.integrity_ok(), "{:?}", result.details);
        assert!(result.valid(), "{:?}", result.details);
        assert_eq!(result.signature, crate::cms::SignatureStatus::Valid);
        assert!(result
            .details
            .iter()
            .any(|d| d.contains("Hash OK -- SHA-1")));
        // No chain validator supplied -> chain not attempted.
        assert_eq!(result.trust_status, Some(TrustStatus::Indeterminate));
    }

    #[test]
    fn a_known_expected_hash_does_not_vouch_for_a_blob_that_is_not_a_signature() {
        // Knowing the exact hash that was submitted proves the spliced bytes
        // survived -- it says nothing about what came back. Filler bytes match
        // no messageDigest, so neither integrity nor validity may hold.
        let (signed, hash) = prepare_and_fake_sign();
        let result = verify_embedded_signature(&signed, Some(&hash), None);
        assert!(result.structure_ok, "{:?}", result.details);
        assert!(!result.hash_ok, "{:?}", result.details);
        assert!(!result.integrity_ok());
        assert!(!result.valid());
        assert!(result
            .details
            .iter()
            .any(|d| d.contains("Hash OK -- SHA-1 matches expected")));
        assert!(result
            .details
            .iter()
            .any(|d| d.contains("Could not extract CMS messageDigest")));
    }

    #[test]
    fn detects_wrong_expected_hash() {
        let (signed, _hash) = prepare_and_fake_sign();
        let wrong = [0u8; 20];
        let result = verify_embedded_signature(&signed, Some(&wrong), None);
        assert!(result.structure_ok);
        assert!(!result.hash_ok);
        assert!(!result.valid());
        assert!(result.details.iter().any(|d| d.contains("Hash MISMATCH")));
    }

    #[test]
    fn unsigned_pdf_reports_no_byterange() {
        let result = verify_embedded_signature(BLANK_LETTER, None, None);
        assert!(!result.structure_ok);
        assert_eq!(result.trust_status, None);
        assert!(result
            .details
            .iter()
            .any(|d| d.contains("No /ByteRange found")));
    }

    #[test]
    fn all_signatures_requires_at_least_one() {
        assert!(verify_all_embedded_signatures(BLANK_LETTER, None).is_err());
        let (signed, _hash) = prepare_and_fake_sign();
        let results = verify_all_embedded_signatures(&signed, None).unwrap();
        assert_eq!(results.len(), 1);
        assert!(results[0].structure_ok);
    }

    #[test]
    fn detached_signature_verifies_against_data() {
        // The committed CMS fixture signs SHA-256("test data") with a real key.
        let result = verify_detached_signature(b"test data", CMS_LEAF, None);
        assert!(result.structure_ok, "{:?}", result.details);
        assert!(result.hash_ok, "{:?}", result.details);
        // The signer's cryptographic signature verifies -> fully valid.
        assert_eq!(result.signature, crate::cms::SignatureStatus::Valid);
        assert!(result.valid(), "{:?}", result.details);
        // Wrong data -> hash mismatch, and no longer valid.
        let bad = verify_detached_signature(b"other data", CMS_LEAF, None);
        assert!(!bad.hash_ok);
        assert!(!bad.valid());
    }

    #[test]
    fn injected_chain_validator_is_applied() {
        let (signed, hash) = prepare_and_fake_sign();
        let validator = |_cms: &[u8]| {
            Some(ChainResult {
                trust: TrustStatus::Trusted,
                trust_anchor: Some("Test CA".to_owned()),
                chain_depth: 2,
                details: vec!["Chain: trusted anchor".to_owned()],
            })
        };
        let result = verify_embedded_signature(&signed, Some(&hash), Some(&validator));
        assert_eq!(result.trust_anchor.as_deref(), Some("Test CA"));
        assert_eq!(result.trust_status, Some(TrustStatus::Trusted));
        assert!(result.details.iter().any(|d| d.contains("trusted anchor")));
    }
}