use crate::error::PdfError;
use crate::objects::{Dict, Object, ObjectId};
use crate::pubsec::der;
use crate::pubsec::signed_data::{parse_signed_data, SignedData};
use crate::reader::document::DocumentReader;
#[derive(Debug, Clone)]
pub struct PdfSignature {
pub byte_range: [i64; 4],
pub contents: Vec<u8>,
pub sub_filter: Option<String>,
pub filter: Option<String>,
pub sig_type: Option<String>,
pub name: Option<String>,
pub reason: Option<String>,
pub location: Option<String>,
pub contact_info: Option<String>,
pub signing_time: Option<String>,
pub signed_data: Option<SignedData>,
pub contents_offset: Option<u64>,
}
impl PdfSignature {
pub fn signed_message(&self, pdf: &[u8]) -> Result<Vec<u8>, PdfError> {
signed_bytes(pdf, &self.byte_range)
}
pub fn is_cms_detached(&self) -> bool {
matches!(
self.sub_filter.as_deref(),
Some("adbe.pkcs7.detached") | Some("ETSI.CAdES.detached")
)
}
pub fn is_doc_timestamp(&self) -> bool {
self.sig_type.as_deref() == Some("DocTimeStamp")
|| self.sub_filter.as_deref() == Some("ETSI.RFC3161")
}
}
#[derive(Debug, Clone)]
pub struct PdfDocTimestamp {
pub byte_range: [i64; 4],
pub contents: Vec<u8>,
pub sub_filter: Option<String>,
pub filter: Option<String>,
}
impl PdfDocTimestamp {
pub fn signed_message(&self, pdf: &[u8]) -> Result<Vec<u8>, PdfError> {
signed_bytes(pdf, &self.byte_range)
}
}
fn promote_doc_timestamp(sig: &PdfSignature) -> Option<PdfDocTimestamp> {
if !sig.is_doc_timestamp() {
return None;
}
Some(PdfDocTimestamp {
byte_range: sig.byte_range,
contents: sig.contents.clone(),
sub_filter: sig.sub_filter.clone(),
filter: sig.filter.clone(),
})
}
pub fn doc_timestamps(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfDocTimestamp>, PdfError> {
let sigs = signatures(reader)?;
Ok(sigs.iter().filter_map(promote_doc_timestamp).collect())
}
pub fn signed_bytes(pdf: &[u8], byte_range: &[i64; 4]) -> Result<Vec<u8>, PdfError> {
let [a, b, c, d] = *byte_range;
if a < 0 || b < 0 || c < 0 || d < 0 {
return Err(PdfError::other(format!(
"PDF /Sig: /ByteRange contains a negative integer ({byte_range:?})"
)));
}
let total = pdf.len() as u64;
let (a, b, c, d) = (a as u64, b as u64, c as u64, d as u64);
let end1 = a
.checked_add(b)
.ok_or_else(|| PdfError::other("PDF /Sig: /ByteRange overflow on first range"))?;
let end2 = c
.checked_add(d)
.ok_or_else(|| PdfError::other("PDF /Sig: /ByteRange overflow on second range"))?;
if end1 > total || end2 > total {
return Err(PdfError::other(format!(
"PDF /Sig: /ByteRange {byte_range:?} extends past file length {total}"
)));
}
if c < end1 {
return Err(PdfError::other(format!(
"PDF /Sig: /ByteRange {byte_range:?} second range starts ({c}) before first range ends ({end1})"
)));
}
let mut out = Vec::with_capacity((b + d) as usize);
out.extend_from_slice(&pdf[a as usize..end1 as usize]);
out.extend_from_slice(&pdf[c as usize..end2 as usize]);
Ok(out)
}
pub fn signatures(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfSignature>, PdfError> {
let root_id = reader.xref().root()?;
let catalog = reader.resolve(root_id)?;
let Object::Dict(catalog) = catalog else {
return Ok(Vec::new());
};
let acro_form = catalog
.entries()
.iter()
.find(|(k, _)| k == "AcroForm")
.map(|(_, v)| v.clone());
let Some(acro_obj) = acro_form else {
return Ok(Vec::new());
};
let acro_dict = match reader.deref(acro_obj)? {
Object::Dict(d) => d,
_ => return Ok(Vec::new()),
};
let fields = acro_dict
.entries()
.iter()
.find(|(k, _)| k == "Fields")
.map(|(_, v)| v.clone());
let Some(Object::Array(field_refs)) = fields else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for item in field_refs {
if let Object::Reference(id) = item {
walk_field(reader, id, None, &mut out)?;
}
}
Ok(out)
}
fn walk_field(
reader: &mut DocumentReader<'_>,
field_id: ObjectId,
inherited_ft: Option<String>,
out: &mut Vec<PdfSignature>,
) -> Result<(), PdfError> {
let field = reader.resolve(field_id)?;
let Object::Dict(d) = field else {
return Ok(());
};
let ft = d
.entries()
.iter()
.find(|(k, _)| k == "FT")
.and_then(|(_, v)| match v {
Object::Name(n) => Some(n.clone()),
_ => None,
})
.or(inherited_ft);
let kids = d
.entries()
.iter()
.find(|(k, _)| k == "Kids")
.map(|(_, v)| v.clone());
if let Some(Object::Array(items)) = kids {
for item in items {
if let Object::Reference(id) = item {
walk_field(reader, id, ft.clone(), out)?;
}
}
return Ok(());
}
if ft.as_deref() != Some("Sig") {
return Ok(());
}
let v = d
.entries()
.iter()
.find(|(k, _)| k == "V")
.map(|(_, v)| v.clone());
let Some(v) = v else {
return Ok(());
};
let sig_dict_obj = reader.deref(v)?;
let Object::Dict(sig_dict) = sig_dict_obj else {
return Ok(());
};
if let Some(parsed) = decode_sig_dict(&sig_dict)? {
out.push(parsed);
}
Ok(())
}
fn decode_sig_dict(dict: &Dict) -> Result<Option<PdfSignature>, PdfError> {
let lookup = |k: &str| {
dict.entries()
.iter()
.find(|(kk, _)| kk == k)
.map(|(_, v)| v.clone())
};
let byte_range = match lookup("ByteRange") {
Some(Object::Array(items)) if items.len() == 4 => {
let mut br = [0i64; 4];
for (i, item) in items.iter().enumerate() {
br[i] = match item {
Object::Integer(n) => *n,
Object::Real(f) => *f as i64,
_ => return Ok(None),
};
}
br
}
_ => return Ok(None),
};
let contents = match lookup("Contents") {
Some(Object::HexString(bytes)) | Some(Object::LiteralString(bytes)) => bytes,
_ => return Ok(None),
};
let sub_filter = match lookup("SubFilter") {
Some(Object::Name(s)) => Some(s),
_ => None,
};
let filter = match lookup("Filter") {
Some(Object::Name(s)) => Some(s),
_ => None,
};
let sig_type = match lookup("Type") {
Some(Object::Name(s)) => Some(s),
_ => None,
};
let signed_data = if matches!(
sub_filter.as_deref(),
Some("adbe.pkcs7.detached") | Some("ETSI.CAdES.detached")
) {
cms_trim_to_outer_sequence(&contents)
.ok()
.and_then(|trimmed| parse_signed_data(&trimmed).ok())
} else {
None
};
Ok(Some(PdfSignature {
byte_range,
contents,
sub_filter,
filter,
sig_type,
name: text_value(&lookup("Name")),
reason: text_value(&lookup("Reason")),
location: text_value(&lookup("Location")),
contact_info: text_value(&lookup("ContactInfo")),
signing_time: text_value(&lookup("M")),
signed_data,
contents_offset: None,
}))
}
fn cms_trim_to_outer_sequence(data: &[u8]) -> Result<Vec<u8>, PdfError> {
let (tlv, _) = der::read_tlv(data)?;
let body_offset = (tlv.body.as_ptr() as usize)
.checked_sub(data.as_ptr() as usize)
.ok_or_else(|| PdfError::other("CMS trim: body pointer math failed"))?;
let total = body_offset
.checked_add(tlv.body.len())
.ok_or_else(|| PdfError::other("CMS trim: total length overflow"))?;
if total > data.len() {
return Err(PdfError::other("CMS trim: SEQUENCE extends past input"));
}
Ok(data[..total].to_vec())
}
fn text_value(o: &Option<Object>) -> Option<String> {
let Some(o) = o else {
return None;
};
match o {
Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
Object::HexString(b) => {
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let utf16: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
Some(String::from_utf16_lossy(&utf16))
} else {
Some(String::from_utf8_lossy(b).into_owned())
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signed_bytes_concatenates_two_ranges() {
let pdf = b"AAAABBBBCCCCDDDD";
let signed = signed_bytes(pdf, &[0, 4, 8, 8]).unwrap();
assert_eq!(signed, b"AAAACCCCDDDD");
}
#[test]
fn signed_bytes_rejects_negative_range() {
let pdf = b"AAAA";
assert!(signed_bytes(pdf, &[-1, 0, 0, 0]).is_err());
assert!(signed_bytes(pdf, &[0, -1, 0, 0]).is_err());
}
#[test]
fn signed_bytes_rejects_out_of_bounds() {
let pdf = b"AAAA";
assert!(signed_bytes(pdf, &[0, 5, 5, 0]).is_err());
assert!(signed_bytes(pdf, &[0, 2, 2, 5]).is_err());
}
#[test]
fn signed_bytes_rejects_overlapping_ranges() {
let pdf = b"AAAABBBBCCCC";
assert!(signed_bytes(pdf, &[0, 4, 3, 9]).is_err());
}
#[test]
fn signed_bytes_overflow_caught() {
let pdf = b"AAAA";
let huge = i64::MAX;
assert!(signed_bytes(pdf, &[huge, huge, 0, 0]).is_err());
}
#[test]
fn pdf_signature_is_cms_detached_recognises_two_subfilters() {
let mut s = PdfSignature {
byte_range: [0, 0, 0, 0],
contents: Vec::new(),
sub_filter: Some("adbe.pkcs7.detached".into()),
filter: None,
sig_type: None,
name: None,
reason: None,
location: None,
contact_info: None,
signing_time: None,
signed_data: None,
contents_offset: None,
};
assert!(s.is_cms_detached());
s.sub_filter = Some("ETSI.CAdES.detached".into());
assert!(s.is_cms_detached());
s.sub_filter = Some("ETSI.RFC3161".into());
assert!(!s.is_cms_detached());
s.sub_filter = None;
assert!(!s.is_cms_detached());
}
}