use std::time::Duration;
use crate::appearance::{compute_optimal_height, compute_optimal_width, get_font};
use crate::constants::{PDF_MAGIC, SHA1_DIGEST_SIZE};
use crate::net::SigningTransport;
use crate::pdf::{
compute_byterange_hash, insert_cms, prepare_pdf_with_sig_field, verify_embedded_signature,
PageSpec, Position, PrepareOptions, PreparedPdf, SIG_HEIGHT, SIG_WIDTH,
};
use crate::signing_response::{check_response_over_content, check_response_over_digest};
use crate::{Result, RevenantError};
#[derive(Debug, Clone)]
pub struct EmbeddedSignatureOptions {
pub page: PageSpec,
pub position: Position,
pub x: Option<f64>,
pub y: Option<f64>,
pub w: Option<f64>,
pub h: Option<f64>,
pub reason: String,
pub name: Option<String>,
pub image_path: Option<String>,
pub fields: Option<Vec<String>>,
pub visible: bool,
pub font: Option<String>,
}
impl Default for EmbeddedSignatureOptions {
fn default() -> Self {
Self {
page: PageSpec::Last,
position: Position::BottomRight,
x: None,
y: None,
w: None,
h: None,
reason: String::new(),
name: None,
image_path: None,
fields: None,
visible: true,
font: None,
}
}
}
fn validate_pdf(pdf: &[u8]) -> Result<()> {
if pdf.is_empty() || !pdf.starts_with(PDF_MAGIC) {
return Err(RevenantError::Pdf(
"Input does not appear to be a PDF file.".to_owned(),
));
}
Ok(())
}
pub fn sign_pdf_detached(
pdf: &[u8],
transport: &dyn SigningTransport,
username: &str,
password: &str,
timeout: Duration,
) -> Result<Vec<u8>> {
validate_pdf(pdf)?;
let cms_der = transport.sign_pdf_detached(pdf, username, password, timeout)?;
check_response_over_content(&cms_der, pdf, "sign_pdf_detached")?;
Ok(cms_der)
}
pub fn sign_hash(
hash: &[u8],
transport: &dyn SigningTransport,
username: &str,
password: &str,
timeout: Duration,
) -> Result<Vec<u8>> {
if hash.len() != SHA1_DIGEST_SIZE {
return Err(RevenantError::Other(format!(
"Expected {SHA1_DIGEST_SIZE}-byte SHA-1 hash, got {} bytes.",
hash.len()
)));
}
let cms_der = transport.sign_hash(hash, username, password, timeout)?;
check_response_over_digest(&cms_der, hash, "sign_hash")?;
Ok(cms_der)
}
pub fn sign_data(
data: &[u8],
transport: &dyn SigningTransport,
username: &str,
password: &str,
timeout: Duration,
) -> Result<Vec<u8>> {
if data.is_empty() {
return Err(RevenantError::Other("Cannot sign empty data.".to_owned()));
}
let cms_der = transport.sign_data(data, username, password, timeout)?;
check_response_over_content(&cms_der, data, "sign_data")?;
Ok(cms_der)
}
pub fn sign_pdf_embedded(
pdf: &[u8],
transport: &dyn SigningTransport,
username: &str,
password: &str,
timeout: Duration,
options: &EmbeddedSignatureOptions,
) -> Result<Vec<u8>> {
validate_pdf(pdf)?;
let mut w = options.w.unwrap_or(SIG_WIDTH);
let mut h = options.h.unwrap_or(SIG_HEIGHT);
if w <= 0.0 || h <= 0.0 {
return Err(RevenantError::Pdf(format!(
"Signature dimensions must be positive, got w={w}, h={h}"
)));
}
if let Some(x) = options.x.filter(|&x| x < 0.0) {
return Err(RevenantError::Pdf(format!(
"Signature x-coordinate must be non-negative, got {x}"
)));
}
if let Some(y) = options.y.filter(|&y| y < 0.0) {
return Err(RevenantError::Pdf(format!(
"Signature y-coordinate must be non-negative, got {y}"
)));
}
log::info!(
"Signing PDF (embedded, {}): {} bytes, position={}",
if options.visible {
"visible"
} else {
"invisible"
},
pdf.len(),
options.position.canonical_name(),
);
if options.visible {
if let Some(fields) = options.fields.as_ref().filter(|f| !f.is_empty()) {
let font = get_font(options.font.as_deref())?;
let has_img = options.image_path.is_some();
if options.w.is_none() {
w = compute_optimal_width(fields, h, has_img, font);
log::debug!("Adaptive signature width: {w:.1} pt");
}
if options.h.is_none() {
h = compute_optimal_height(fields, w, has_img, font);
log::debug!("Adaptive signature height: {h:.1} pt");
}
}
}
let manual_xy = options.x.zip(options.y);
let prepare_opts = PrepareOptions {
page: options.page,
position: options.position,
manual_xy,
size: (w, h),
reason: &options.reason,
name: options.name.as_deref(),
image_path: options.image_path.as_deref(),
fields: options.fields.clone(),
visible: options.visible,
font: options.font.as_deref(),
};
let PreparedPdf {
bytes: prepared,
contents_hex_offset: hex_start,
contents_hex_len: hex_len,
} = prepare_pdf_with_sig_field(pdf, &prepare_opts)?;
let mut br_data = Vec::with_capacity(prepared.len().saturating_sub(hex_len + 1));
br_data.extend_from_slice(&prepared[..hex_start]);
br_data.extend_from_slice(&prepared[hex_start + hex_len + 1..]);
let cms_der = sign_data(&br_data, transport, username, password, timeout)?;
let signed = insert_cms(&prepared, hex_start, hex_len, &cms_der)?;
if signed.len() != prepared.len() {
return Err(RevenantError::Pdf(format!(
"insert_cms changed PDF size: {} -> {}",
prepared.len(),
signed.len()
)));
}
let br_hash = compute_byterange_hash(&prepared, hex_start, hex_len)?;
let result = verify_embedded_signature(&signed, Some(&br_hash), None);
if !result.valid() {
let detail = result.details.join("\n ");
log::error!("Post-sign verification failed: {detail}");
return Err(RevenantError::Pdf(format!(
"Post-sign verification FAILED:\n {detail}\nThe signed PDF may be corrupt -- not saved."
)));
}
log::info!("Signed PDF complete: {} bytes", signed.len());
Ok(signed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cms::{extract_signature_data, find_byteranges};
use crate::testutil::{sign_cms_detached, TestSigner};
const BLANK_LETTER: &[u8] = include_bytes!("pdf/testdata/blank_letter.pdf");
fn timeout() -> Duration {
Duration::from_secs(30)
}
fn fake_cms() -> Vec<u8> {
let mut der = vec![0x30, 0x81, 0xC8];
der.extend(std::iter::repeat_n(0xAB, 200));
der
}
struct FakeSigner {
cms: Vec<u8>,
}
impl SigningTransport for FakeSigner {
fn sign_data(&self, _: &[u8], _: &str, _: &str, _: Duration) -> Result<Vec<u8>> {
Ok(self.cms.clone())
}
fn sign_hash(&self, _: &[u8], _: &str, _: &str, _: Duration) -> Result<Vec<u8>> {
Ok(self.cms.clone())
}
fn sign_pdf_detached(&self, _: &[u8], _: &str, _: &str, _: Duration) -> Result<Vec<u8>> {
Ok(self.cms.clone())
}
}
fn signer() -> TestSigner {
TestSigner
}
#[test]
fn embedded_visible_roundtrips() {
let opts = EmbeddedSignatureOptions {
name: Some("Jane Signer".to_owned()),
reason: "Approved".to_owned(),
..Default::default()
};
let signed =
sign_pdf_embedded(BLANK_LETTER, &signer(), "u", "p", timeout(), &opts).unwrap();
assert_eq!(find_byteranges(&signed).unwrap().len(), 1);
let (signed_data, cms) = extract_signature_data(&signed).unwrap();
assert_eq!(cms, sign_cms_detached(&signed_data));
}
#[test]
fn embedded_rejects_a_response_that_is_not_a_signature() {
let transport = FakeSigner { cms: fake_cms() };
let opts = EmbeddedSignatureOptions {
name: Some("Jane Signer".to_owned()),
..Default::default()
};
let err =
sign_pdf_embedded(BLANK_LETTER, &transport, "u", "p", timeout(), &opts).unwrap_err();
assert!(matches!(err, RevenantError::SigningResponse(_)), "{err}");
}
#[test]
fn embedded_rejects_a_signature_over_someone_elses_bytes() {
let transport = FakeSigner {
cms: sign_cms_detached(b"a different document entirely"),
};
let opts = EmbeddedSignatureOptions {
name: Some("Jane Signer".to_owned()),
..Default::default()
};
let err =
sign_pdf_embedded(BLANK_LETTER, &transport, "u", "p", timeout(), &opts).unwrap_err();
assert!(matches!(err, RevenantError::SigningResponse(_)), "{err}");
}
#[test]
fn embedded_invisible_roundtrips() {
let opts = EmbeddedSignatureOptions {
visible: false,
name: Some("Invisible".to_owned()),
..Default::default()
};
let signed =
sign_pdf_embedded(BLANK_LETTER, &signer(), "u", "p", timeout(), &opts).unwrap();
assert_eq!(find_byteranges(&signed).unwrap().len(), 1);
}
#[test]
fn embedded_autosizes_from_explicit_fields() {
let opts = EmbeddedSignatureOptions {
fields: Some(vec![
"A very long signer display name that forces a wide box".to_owned(),
"SSN: 1234567890".to_owned(),
]),
..Default::default()
};
let signed =
sign_pdf_embedded(BLANK_LETTER, &signer(), "u", "p", timeout(), &opts).unwrap();
assert_eq!(find_byteranges(&signed).unwrap().len(), 1);
}
#[test]
fn embedded_rejects_non_pdf() {
let opts = EmbeddedSignatureOptions::default();
let err =
sign_pdf_embedded(b"not a pdf", &signer(), "u", "p", timeout(), &opts).unwrap_err();
assert!(matches!(err, RevenantError::Pdf(_)));
}
#[test]
fn embedded_rejects_nonpositive_dimensions() {
let opts = EmbeddedSignatureOptions {
w: Some(0.0),
..Default::default()
};
let err =
sign_pdf_embedded(BLANK_LETTER, &signer(), "u", "p", timeout(), &opts).unwrap_err();
assert!(matches!(err, RevenantError::Pdf(_)));
}
#[test]
fn embedded_rejects_negative_coordinates() {
let opts = EmbeddedSignatureOptions {
x: Some(-1.0),
y: Some(10.0),
..Default::default()
};
let err =
sign_pdf_embedded(BLANK_LETTER, &signer(), "u", "p", timeout(), &opts).unwrap_err();
assert!(matches!(err, RevenantError::Pdf(_)));
}
#[test]
fn embedded_fails_on_corrupt_cms() {
let transport = FakeSigner {
cms: vec![0x30, 0x02, 0xAB, 0xCD],
};
let opts = EmbeddedSignatureOptions {
name: Some("X".to_owned()),
..Default::default()
};
let err =
sign_pdf_embedded(BLANK_LETTER, &transport, "u", "p", timeout(), &opts).unwrap_err();
assert!(matches!(err, RevenantError::SigningResponse(_)), "{err}");
}
#[test]
fn embedded_fails_when_the_splice_is_tampered_with() {
let opts = EmbeddedSignatureOptions {
name: Some("Jane Signer".to_owned()),
..Default::default()
};
let signed =
sign_pdf_embedded(BLANK_LETTER, &signer(), "u", "p", timeout(), &opts).unwrap();
let result = crate::pdf::verify_embedded_signature(&signed, None, None);
assert!(result.valid(), "{:?}", result.details);
let mut tampered = signed.clone();
let victim = tampered.len() / 2;
tampered[victim] ^= 0x01;
let after = crate::pdf::verify_embedded_signature(&tampered, None, None);
assert!(!after.valid(), "{:?}", after.details);
}
#[test]
fn detached_signs_and_validates_input() {
let cms = sign_pdf_detached(BLANK_LETTER, &signer(), "u", "p", timeout()).unwrap();
assert_eq!(cms, sign_cms_detached(BLANK_LETTER));
let err = sign_pdf_detached(b"nope", &signer(), "u", "p", timeout()).unwrap_err();
assert!(matches!(err, RevenantError::Pdf(_)));
}
#[test]
fn sign_hash_validates_length() {
assert!(sign_hash(&[0u8; 20], &signer(), "u", "p", timeout()).is_ok());
let err = sign_hash(&[0u8; 19], &signer(), "u", "p", timeout()).unwrap_err();
assert!(matches!(err, RevenantError::Other(_)));
}
#[test]
fn sign_data_rejects_empty() {
assert!(sign_data(b"data", &signer(), "u", "p", timeout()).is_ok());
let err = sign_data(b"", &signer(), "u", "p", timeout()).unwrap_err();
assert!(matches!(err, RevenantError::Other(_)));
}
}