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};
pub type ChainValidator<'a> = dyn Fn(&[u8]) -> Option<ChainResult> + 'a;
#[derive(Debug, Clone)]
pub struct VerificationResult {
pub structure_ok: bool,
pub hash_ok: bool,
pub signature: SignatureStatus,
pub coverage: ByteRangeCoverage,
pub ltv_enabled: bool,
pub details: Vec<String>,
pub signer: Option<CertInfo>,
pub trust_anchor: Option<String>,
pub trust_status: Option<TrustStatus>,
}
impl VerificationResult {
#[must_use]
pub fn integrity_ok(&self) -> bool {
self.structure_ok && self.hash_ok
}
#[must_use]
pub fn valid(&self) -> bool {
self.integrity_ok() && self.signature.is_valid()
}
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,
}
}
}
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();
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()));
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
));
}
let structure_ok = check_cms_structure(&cms_der, &mut details);
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}"));
}
let signature = verify_signer_signature(&cms_der, Some(&signed_data));
let hash_ok = verify_hash(
&signed_data,
&cms_der,
expected_hash,
signature,
&mut details,
);
details.push(signature.describe());
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),
};
apply_chain(&mut result, &cms_der, validate_chain);
result
}
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
}
}
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) {
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
}
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());
}
}
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}"),
}
}
#[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
}
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();
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)
}
#[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}"));
}
let signature = verify_signer_signature(cms_der, Some(data_bytes));
details.push(signature.describe());
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) {
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,
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");
fn prepare_and_sign() -> (Vec<u8>, [u8; 20]) {
prepare_and_splice(crate::testutil::sign_cms_detached)
}
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);
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")));
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() {
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() {
let result = verify_detached_signature(b"test data", CMS_LEAF, None);
assert!(result.structure_ok, "{:?}", result.details);
assert!(result.hash_ok, "{:?}", result.details);
assert_eq!(result.signature, crate::cms::SignatureStatus::Valid);
assert!(result.valid(), "{:?}", result.details);
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")));
}
}