nigig-org/crates/apps/pdf/pdf-document/tests/sign_document_roundtrip.rs
andodeki 374af5ccad
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): the five Phase 6 bullets the status line omitted
ADR 0027. Asked whether Phase 6 was 100% complete, I checked the plan's
bullets against the code instead of answering from the status line. Five
were not implemented and the status line named none of them:

  detached/ATTACHED signatures   /SubFilter hardcoded to adbe.pkcs7.detached
  PAdES basics                   ETSI.CAdES.detached was a string in a match
  external_signing_test.dart     absent; SigningIdentity needs an in-memory key
  OCSP/CRL lookup                CRL only; OCSP counted, never parsed
  Fulcio identity                absent (optional in the plan)

This is the second time. ADR 0021 recorded the same failure in Phase 4 and
wrote the rule meant to prevent it — enumerate criteria from the plan text
first, then mark each done or explicitly deferred. I wrote that rule and
then produced another prose summary of what I had built. A summary written
from the work cannot show what the work omitted.

PAdES is a real profile, not a label. CAdES signs a set of signed
attributes, one carrying the document digest, and the signature is over
those attributes re-tagged as a SET (RFC 5652 5.4) rather than over the
[0] IMPLICIT SEQUENCE they are carried in. Verification checks the
messageDigest attribute against the document as well as verifying the
attribute signature; without that, a signature over somebody else's digest
would be accepted. /SubFilter now comes from the profile, so a document
cannot claim CAdES while carrying plain PKCS#7.

ExternalSigner is a trait: bytes in, signature out. A smartcard or KMS
never hands out its key, so SigningIdentity could not represent one.
SigningIdentity implements the trait rather than sitting beside it, so
there is one signing path — a second path for hardware keys would be a
second place the byte range could be computed differently.

OCSP is decoded with the der crate already present rather than adding the
ocsp crate for two fields. Revoked from any response beats Good from any
other.

Attached signatures are REFUSED, not deferred. Both attached profiles
(adbe.pkcs7.sha1, adbe.x509.rsa_sha1) are SHA-1 based, and SHA-1 is broken
for signatures. They are parsed so such documents can be read; they cannot
be written, enforced by the absence of a SignatureProfile variant. Same
decision as RC4 in ADR 0024. Recorded as refused rather than not-done,
because "not done" invites someone to finish it.

Four mutations, all killed first attempt: messageDigest not compared,
CAdES verified against the wrong bytes, /SubFilter hardcoded again, OCSP
revoked read as good.

The status line is now the plan's own bullets in a table, one row per spec
item, not prose. Two wrong status lines in the same direction is a pattern,
and the fix is structural: a missing row is visible, a missing sentence is
not. Four rows are left unticked — Fulcio, independent review, Acrobat
interoperability, and signing a document that already has an AcroForm.

qpdf accepts documents under both profiles. pdf: 1289 passed (was 1276).
Coverage 87.96%.
2026-08-18 12:06:59 +00:00

395 lines
14 KiB
Rust

//! One-call signing of a real document, and the pieces around it.
//!
//! `signing_roundtrip.rs` signs raw byte strings; this signs a **PDF**,
//! reads it back through `PdfDocument::signatures()`, and verifies the
//! signature over the byte range the reader reports. That is the Phase 6
//! exit criterion end to end.
//!
//! See `REVIEWS/adr/0026-pdf-signing-security-review.md`.
use nigig_pdf_cos::writer::PdfDocBuilder;
use nigig_pdf_document::sign::{
revocation_status, sign_document, signature_appearance_content, signed_bytes,
stapled_revocation, verify_pkcs7, RevocationStatus, SignatureMetadata, SignatureProfile,
SigningIdentity, StapledRevocation, TrustAnchors,
};
use nigig_pdf_document::PdfDocument;
include!("cert_helpers.rs");
/// A small unsigned document.
fn unsigned_document() -> Vec<u8> {
let mut builder = PdfDocBuilder::new();
builder.add_page(
300.0,
400.0,
b"BT /F1 12 Tf 20 200 Td (a contract worth signing) Tj ET",
);
builder.finish()
}
/// Sign `source` with a fresh RSA identity, returning the bytes and the
/// certificate to trust.
fn sign(source: &[u8], metadata: SignatureMetadata) -> (Vec<u8>, Vec<u8>) {
let mut doc = PdfDocument::parse(source).expect("source parses");
let trailer = doc.trailer().clone();
let root_ref = trailer.get_ref("Root").expect("root");
let catalog = doc
.resolve_ref(root_ref)
.expect("catalogue")
.as_dict()
.expect("catalogue is a dict")
.clone();
let max = doc.max_object_number();
let (key, cert) = self_signed_rsa();
let identity = SigningIdentity::Rsa {
key: Box::new(key),
chain: vec![cert.clone()],
};
let signed = sign_document(
source,
&trailer,
&catalog,
max,
&identity,
&metadata,
"Signature1",
8192,
SignatureProfile::Pkcs7Detached,
)
.expect("signing succeeds");
(signed, cert)
}
// ------------------------------------------------------- the round trip
#[test]
fn a_signed_document_verifies_end_to_end() {
let source = unsigned_document();
let (signed, cert) = sign(
&source,
SignatureMetadata {
name: Some("Ada Lovelace".to_string()),
reason: Some("Approval".to_string()),
..Default::default()
},
);
let mut doc = PdfDocument::parse(&signed).expect("the signed document parses");
let report = doc.signatures().expect("signature report");
assert_eq!(report.signatures.len(), 1, "expected exactly one signature");
let signature = &report.signatures[0];
assert!(
signature.covers_whole_file,
"the byte range must reach the last byte, or content is unsigned"
);
let covered = signed_bytes(&signed, &signature.byte_range).expect("byte range is sound");
let anchors = TrustAnchors::none().with_root_der(cert);
let outcome = verify_pkcs7(&signature.contents, &covered, &anchors);
assert!(
outcome.is_valid(),
"the signature did not verify: {:?}",
outcome.problems
);
}
#[test]
fn the_signature_metadata_survives_into_the_document() {
let source = unsigned_document();
let (signed, _cert) = sign(
&source,
SignatureMetadata {
name: Some("Ada Lovelace".to_string()),
reason: Some("I approve this".to_string()),
location: Some("Nairobi".to_string()),
signing_time: Some("D:20260818120000Z".to_string()),
..Default::default()
},
);
let mut doc = PdfDocument::parse(&signed).expect("parses");
let report = doc.signatures().expect("report");
let signature = &report.signatures[0];
assert_eq!(signature.name.as_deref(), Some("Ada Lovelace"));
assert_eq!(signature.reason.as_deref(), Some("I approve this"));
assert_eq!(signature.location.as_deref(), Some("Nairobi"));
assert_eq!(signature.signing_time.as_deref(), Some("D:20260818120000Z"));
}
#[test]
fn the_original_content_is_still_readable_after_signing() {
// Signing appends a revision; it must not disturb what was there.
let source = unsigned_document();
let (signed, _cert) = sign(&source, SignatureMetadata::default());
let mut doc = PdfDocument::parse(&signed).expect("parses");
assert_eq!(doc.page_count(), 1);
let page = doc.page(0).expect("page 0");
assert_eq!(page.media_box, [0.0, 0.0, 300.0, 400.0]);
assert!(
String::from_utf8_lossy(&page.content_data).contains("a contract worth signing"),
"the page content changed"
);
}
#[test]
fn editing_a_signed_document_is_detectable() {
// The property a signature exists for. Appending a revision leaves the
// signature valid over what it covered, and `covers_whole_file` must
// become false so a reader can say so.
let source = unsigned_document();
let (signed, cert) = sign(&source, SignatureMetadata::default());
let mut tampered = signed.clone();
tampered.extend_from_slice(b"\n% an appended revision\n");
let mut doc = PdfDocument::parse(&tampered).expect("parses");
let report = doc.signatures().expect("report");
let signature = &report.signatures[0];
assert!(
!signature.covers_whole_file,
"appended bytes must fall outside the signed range"
);
// The signature is still cryptographically valid over its own range —
// reporting otherwise would be wrong — but the document is longer than
// what was signed.
let covered = signed_bytes(&tampered, &signature.byte_range).expect("range");
let anchors = TrustAnchors::none().with_root_der(cert);
assert!(verify_pkcs7(&signature.contents, &covered, &anchors).is_valid());
}
#[test]
fn altering_signed_content_breaks_the_signature() {
let source = unsigned_document();
let (signed, cert) = sign(&source, SignatureMetadata::default());
let mut doc = PdfDocument::parse(&signed).expect("parses");
let report = doc.signatures().expect("report");
let byte_range = report.signatures[0].byte_range.clone();
let contents = report.signatures[0].contents.clone();
// Flip a byte inside the first covered range.
let mut tampered = signed.clone();
tampered[100] ^= 0x01;
let covered = signed_bytes(&tampered, &byte_range).expect("range");
let anchors = TrustAnchors::none().with_root_der(cert);
assert!(
!verify_pkcs7(&contents, &covered, &anchors).is_valid(),
"a modified document still verified"
);
}
#[test]
fn a_signature_field_appears_in_the_acroform() {
// A signature dictionary nothing points at is invisible to every
// reader, which is how the first attempt at this failed.
let source = unsigned_document();
let (signed, _cert) = sign(&source, SignatureMetadata::default());
let mut doc = PdfDocument::parse(&signed).expect("parses");
let form = doc
.acroform()
.expect("no error")
.expect("the signed document must carry a form");
let field = form
.find_field("Signature1")
.expect("the signature field must be named and findable");
assert_eq!(
field.field_type,
nigig_pdf_document::form::FieldType::Signature
);
}
#[test]
fn signing_twice_leaves_both_signatures_readable() {
// The second signature covers the first, which is what makes a
// countersignature meaningful.
let source = unsigned_document();
let (once, _first_cert) = sign(&source, SignatureMetadata::default());
let (twice, second_cert) = sign(&once, SignatureMetadata::default());
let mut doc = PdfDocument::parse(&twice).expect("parses");
let report = doc.signatures().expect("report");
assert_eq!(
report.signatures.len(),
1,
"the second signing replaces the form; both-signature support is \
the countersignature case and is not claimed"
);
// What is claimed: the second signature is valid over the whole file.
let signature = &report.signatures[0];
assert!(signature.covers_whole_file);
let covered = signed_bytes(&twice, &signature.byte_range).expect("range");
let anchors = TrustAnchors::none().with_root_der(second_cert);
assert!(verify_pkcs7(&signature.contents, &covered, &anchors).is_valid());
}
// ------------------------------------------------------- appearances
#[test]
fn a_signature_appearance_names_the_signer() {
let content = signature_appearance_content(
200.0,
60.0,
&SignatureMetadata {
name: Some("Ada Lovelace".to_string()),
reason: Some("Approval".to_string()),
..Default::default()
},
"Helv",
);
let text = String::from_utf8_lossy(&content);
assert!(text.contains("Signed by: Ada Lovelace"), "{text}");
assert!(text.contains("Reason: Approval"), "{text}");
// A border, so the field is visible as a field.
assert!(text.contains(" re S"), "no border drawn: {text}");
assert!(text.contains("/Helv"), "the font resource is not named");
}
#[test]
fn a_claimed_signing_time_is_labelled_as_claimed() {
// A self-declared /M carries no authority. Rendering it as a bare
// timestamp would dress a claim up as a fact.
let content = signature_appearance_content(
200.0,
60.0,
&SignatureMetadata {
signing_time: Some("D:20260818120000Z".to_string()),
..Default::default()
},
"Helv",
);
let text = String::from_utf8_lossy(&content);
assert!(text.contains("Time claimed:"), "{text}");
}
#[test]
fn an_appearance_with_no_metadata_still_says_something() {
// An empty box tells a reader nothing about why it is there.
let content = signature_appearance_content(120.0, 40.0, &SignatureMetadata::default(), "Helv");
let text = String::from_utf8_lossy(&content);
assert!(text.contains("Digitally signed"), "{text}");
}
#[test]
fn appearance_text_is_escaped() {
// A name containing a parenthesis would otherwise end the string early
// and corrupt every operator after it.
let content = signature_appearance_content(
200.0,
60.0,
&SignatureMetadata {
name: Some("Ada (the) Lovelace".to_string()),
..Default::default()
},
"Helv",
);
let text = String::from_utf8_lossy(&content);
assert!(text.contains(r"Ada \(the\) Lovelace"), "{text}");
}
// ------------------------------------------------------- revocation
#[test]
fn a_document_with_no_dss_reports_no_stapled_material() {
let source = unsigned_document();
let mut doc = PdfDocument::parse(&source).expect("parses");
let material = stapled_revocation(&mut doc);
assert!(material.is_empty());
}
#[test]
fn no_revocation_material_means_unknown_not_good() {
// The important default. Treating "no information" as "not revoked" is
// a claim the verifier cannot support.
let empty = StapledRevocation::default();
assert_eq!(
revocation_status(&empty, &[0x01]),
RevocationStatus::Unknown
);
}
#[test]
fn a_serial_listed_in_a_stapled_crl_is_revoked() {
// Build a CRL listing serial 0x42 and check it is found.
let crl = crl_listing_serial(0x42);
let material = StapledRevocation {
ocsp_responses: Vec::new(),
crls: vec![crl],
};
assert_eq!(
revocation_status(&material, &[0x42]),
RevocationStatus::Revoked
);
// A serial the CRL covers but does not list is good.
assert_eq!(
revocation_status(&material, &[0x43]),
RevocationStatus::Good
);
}
/// A minimal CRL listing one revoked serial number.
fn crl_listing_serial(serial: u8) -> Vec<u8> {
use der::asn1::{BitString, UtcTime};
use der::Encode;
let issuer_key = {
let mut rng = TestRng;
rsa::RsaPrivateKey::new(&mut rng, 1024).expect("key")
};
let _ = &issuer_key;
let entry = x509_cert::crl::RevokedCert {
serial_number: x509_cert::serial_number::SerialNumber::new(&[serial]).expect("serial"),
revocation_date: x509_cert::time::Time::UtcTime(
UtcTime::from_unix_duration(std::time::Duration::from_secs(1_700_000_000))
.expect("time"),
),
crl_entry_extensions: None,
};
let tbs = x509_cert::crl::TbsCertList {
version: x509_cert::Version::V2,
signature: x509_cert::spki::AlgorithmIdentifierOwned {
oid: "1.2.840.113549.1.1.11".parse().expect("static OID"),
parameters: None,
},
issuer: {
let cn: der::asn1::ObjectIdentifier = "2.5.4.3".parse().expect("static OID");
let value = der::asn1::Utf8StringRef::new("nigig-crl-issuer").expect("utf8");
let atv = x509_cert::attr::AttributeTypeAndValue {
oid: cn,
value: der::Any::from(value),
};
let mut set = der::asn1::SetOfVec::new();
set.insert(atv).expect("set");
x509_cert::name::Name::from(x509_cert::name::RdnSequence::from(vec![
x509_cert::name::RelativeDistinguishedName::from(set),
]))
},
this_update: x509_cert::time::Time::UtcTime(
UtcTime::from_unix_duration(std::time::Duration::from_secs(1_700_000_000))
.expect("time"),
),
next_update: None,
revoked_certificates: Some(vec![entry]),
crl_extensions: None,
};
x509_cert::crl::CertificateList {
tbs_cert_list: tbs,
signature_algorithm: x509_cert::spki::AlgorithmIdentifierOwned {
oid: "1.2.840.113549.1.1.11".parse().expect("static OID"),
parameters: None,
},
signature: BitString::from_bytes(&[0u8; 64]).expect("bit string"),
}
.to_der()
.expect("CRL DER")
}