Merge r2 track t14: signatures

# Conflicts:
#	ATTRIBUTION.toml
#	crates/annot/src/tests.rs
This commit is contained in:
aiongg
2026-10-22 02:22:02 +00:00
26 changed files with 1025 additions and 95 deletions
+1 -1
View File
@@ -559,7 +559,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
c.push_str("h\n");
}
}
c.push_str("f*\n");
c.push_str("f\n");
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
+1 -1
View File
@@ -394,7 +394,7 @@ pub enum Shape {
by: Option<String>,
},
/// A typed signature or initials: filled outlines (each contour as points normalised to
/// `rect`, 0–1, y up), drawn with the even-odd rule.
/// `rect`, 0–1, y up), drawn with the non-zero winding rule.
TypedSignature {
rect: [f64; 4],
contours: Vec<Vec<[f64; 2]>>,
+13
View File
@@ -1491,3 +1491,16 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
assert_eq!(walk.next(), None);
assert_eq!(page_refs(&doc).unwrap(), [first, second]);
}
/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same
/// winding direction where strokes cross, so typed signature appearances must fill with the
/// non-zero winding operator (`f`), not even-odd (`f*`).
#[test]
fn typed_signature_uses_nonzero_winding_fill() {
let mut doc = fixture();
let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]];
let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap();
let ap = ap_content(&doc, &list(&doc, 0)[idx]);
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
}
+1 -1
View File
@@ -769,7 +769,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "id", "out"],
)),
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, a .p7b/.p7c bundle, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }),
&[],
)),
+7
View File
@@ -5124,6 +5124,13 @@ fn digital_ids_signing_and_validation_through_tools() {
assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_))));
assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
// The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one.
use pdfcraft_engine::sign::{cms, der, pkcs12};
let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw;
let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]);
std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap();
assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1);
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid");
}
/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file.
+20
View File
@@ -51,6 +51,18 @@ pub fn cjk_width(s: &str, size: f64) -> f64 {
.sum()
}
/// Approximate advance of `s` in Inter Regular at `size` points.
pub fn inter_width(s: &str, size: f64) -> f64 {
use skrifa::MetadataProvider as _;
let Ok(font) = skrifa::FontRef::new(INTER_REGULAR) else { return helvetica_width(s, size) };
let loc = skrifa::instance::LocationRef::default();
let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em.max(1) as f64;
let scale = size / upem;
let charmap = font.charmap();
let advances = font.glyph_metrics(skrifa::instance::Size::unscaled(), loc);
s.chars().map(|c| charmap.map(c).and_then(|gid| advances.advance_width(gid)).unwrap_or(0.0) as f64 * scale).sum()
}
/// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block).
fn is_wide(c: char) -> bool {
matches!(c,
@@ -323,6 +335,14 @@ mod tests {
assert_eq!(undrawn_mark(&d), None);
}
#[test]
fn inter_width_measures_latin_extended_and_ascii() {
assert!(inter_width("Luka Petravić", 12.0) > 0.0);
assert_eq!(inter_width("", 12.0), 0.0);
let w = inter_width("čžšć", 10.0);
assert!(w > 10.0 && w < 100.0);
}
#[test]
fn widths_wrap_and_encode() {
assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0);
+7
View File
@@ -83,6 +83,13 @@ needs a prompt to open is listed and asks at signing (issue #179).
attributes are still verified over their own exact bytes (signed attributes written with an
indefinite length are re-encoded as DER first, as RFC 5652 §5.4 has them signed). Nesting is
bounded, and the details say that the signature was BER. High tag numbers are not read.
- **One content type, one message digest.** A CMS whose signed attributes repeat the content type
or the message digest, or give either of them more or fewer than one value, is not read (RFC
5652 §11.1, §11.2). Neither is one whose content-type attribute differs from `eContentType`:
the signature covers the attribute but not `eContentType`. Both content types must be object
identifiers, and a PDF signature's must be data. A signature with signed attributes but no
content-type attribute is read anyway, with a note, although §11.1 requires one: then
`eContentType` alone says that the content is data.
- **Later changes are classified narrowly.** Only the catalog's own `/Metadata` stream is the
document's XMP, and only objects reached through the `/DSS` keys above are the security store. A
`/Type /Metadata` or `/Type /DSS` *label* on any other dictionary or stream (a page's Form
+86 -13
View File
@@ -50,10 +50,67 @@ pub struct SignedData {
pub signer: SignerInfo,
/// Encapsulated content (`adbe.pkcs7.sha1` carries the document digest here).
pub content: Option<Vec<u8>>,
/// `eContentType`, the type of the encapsulated content: data for a PDF signature, TSTInfo
/// for an RFC 3161 token. The signature does not cover it.
pub econtent_type: String,
/// Encoding irregularities that were tolerated (BER instead of DER, …), for the details.
pub quirks: Vec<&'static str>,
}
/// The X.509 certificates in a SignedData `certificates` field (`[0] IMPLICIT CertificateSet`).
/// Other certificate formats (attribute certificates) and certificates that don't parse are
/// skipped.
fn carried_certificates(set: &Tlv) -> Result<Vec<Certificate>, SignError> {
let mut out = Vec::new();
for c in set.children()? {
if c.tag == tag::SEQUENCE
&& let Ok(cert) = Certificate::parse(c.raw)
{
out.push(cert);
}
}
Ok(out)
}
/// The certificates of a PKCS #7 certificate bundle (`.p7b`, `.p7c`): a SignedData with no
/// signers ("degenerate", RFC 5652 §5.2), which is how Windows, Acrobat and certificate
/// authorities hand out a chain or a set of CA certificates. Signers and CRLs, if present, are
/// ignored; only the certificates are read. DER or BER.
pub fn certificate_bundle(bytes: &[u8]) -> Result<Vec<Certificate>, SignError> {
let (ci, rest) = Tlv::parse_ber(bytes)?;
// A trust file is the bundle alone: bytes after it mean a damaged or spliced file.
if !rest.is_empty() {
return Err(bad("data after the bundle"));
}
let ci = ci.expect(tag::SEQUENCE, "ContentInfo")?.children()?;
let [ct, content] = ci.as_slice() else { return Err(bad("ContentInfo")) };
if ct.oid()? != SIGNED_DATA {
return Err(bad("not SignedData"));
}
let sd = content.expect(tag::ctx(0), "content")?.inner()?.children()?;
// version, digestAlgorithms, encapContentInfo, the optional [0] certificates and [1] CRLs,
// then signerInfos (RFC 5652 §5.1).
let [version, digests, encap, middle @ .., signers] = sd.as_slice() else { return Err(bad("SignedData")) };
let shape = [(version, tag::INTEGER), (digests, tag::SET), (encap, tag::SEQUENCE), (signers, tag::SET)];
// At most one [0] certificates and one [1] CRLs, in that order.
const CERTS: u8 = tag::ctx(0);
const CRLS: u8 = tag::ctx(1);
let middle_ok = matches!(middle.iter().map(|t| t.tag).collect::<Vec<_>>().as_slice(), [] | [CERTS] | [CRLS] | [CERTS, CRLS]);
// encapContentInfo starts with its eContentType OID.
let encap_ok = encap.children()?.first().is_some_and(|t| t.tag == tag::OID);
if shape.iter().any(|(t, want)| t.tag != *want) || !middle_ok || !encap_ok {
return Err(bad("SignedData"));
}
let mut certificates = Vec::new();
for t in middle.iter().filter(|t| t.tag == tag::ctx(0)) {
certificates.extend(carried_certificates(t)?);
}
if certificates.is_empty() {
return Err(bad("the bundle holds no certificate"));
}
Ok(certificates)
}
fn bad(what: &str) -> SignError {
SignError::Malformed(format!("CMS: {what}"))
}
@@ -80,6 +137,7 @@ impl SignedData {
let _version = it.next();
let _digest_algs = it.next();
let encap = it.next().ok_or_else(|| bad("encapContentInfo"))?.children()?;
let econtent_type = encap.first().ok_or_else(|| bad("eContentType"))?.oid()?;
let content = match encap.get(1) {
Some(c) if c.tag == tag::ctx(0) => {
// BER signers may split the content into a constructed OCTET STRING.
@@ -95,23 +153,23 @@ impl SignedData {
let mut signer_infos = None;
for t in it {
match t.tag {
t2 if t2 == tag::ctx(0) => {
for c in t.children()? {
// Other certificate formats (attribute certificates) are skipped.
if c.tag == tag::SEQUENCE
&& let Ok(cert) = Certificate::parse(c.raw)
{
certificates.push(cert);
}
}
}
t2 if t2 == tag::ctx(0) => certificates.extend(carried_certificates(&t)?),
tag::SET => signer_infos = Some(t),
_ => {}
}
}
let infos = signer_infos.ok_or_else(|| bad("no signerInfos"))?.children()?;
let si = infos.first().ok_or_else(|| bad("no signer"))?;
Ok(SignedData { certificates, signer: parse_signer(si)?, content, quirks })
let signer = parse_signer(si)?;
// RFC 5652 §11.1: the content-type attribute names the type of the encapsulated content.
// The signature covers the attribute but not `eContentType`, so when they differ, one of
// them was written wrong or changed after signing.
if let Some(signed) = &signer.content_type
&& *signed != econtent_type
{
return Err(bad("the signed content type is not the encapsulated content type"));
}
Ok(SignedData { certificates, signer, content, econtent_type, quirks })
}
/// The signer's certificate among those carried.
@@ -204,13 +262,28 @@ fn parse_signer(si: &Tlv<'_>) -> Result<SignerInfo, SignError> {
if let Some(t) = set.first_mut() {
*t = tag::SET;
}
let (mut seen_type, mut seen_digest) = (false, false);
for a in next.children()? {
let p = a.children()?;
let Some(o) = p.first().and_then(|o| o.oid().ok()) else { continue };
let value = p.get(1).and_then(|v| v.children().ok()).and_then(|v| v.into_iter().next());
let values = p.get(1).and_then(|v| v.children().ok()).unwrap_or_default();
// RFC 5652 §11.1 and §11.2: one content-type and one message-digest attribute, each
// with one value. With a second one, two readers could each take a different value.
let (seen, what) = match o.as_str() {
CONTENT_TYPE => (Some(&mut seen_type), "content type"),
MESSAGE_DIGEST => (Some(&mut seen_digest), "message digest"),
_ => (None, ""),
};
if let Some(seen) = seen
&& (std::mem::replace(seen, true) || values.len() != 1)
{
return Err(bad(&format!("the signed attributes must hold one {what} with one value")));
}
let value = values.into_iter().next();
match (o.as_str(), value) {
(MESSAGE_DIGEST, Some(v)) => message_digest = Some(v.value.to_vec()),
(CONTENT_TYPE, Some(v)) => content_type = v.oid().ok(),
// A value that is not an OID would skip every content-type check after this.
(CONTENT_TYPE, Some(v)) => content_type = Some(v.oid()?),
(SIGNING_TIME, Some(v)) => signing_time = v.time().ok(),
(SIGNING_CERT_V2 | SIGNING_CERT, _) => signing_certificate = true,
_ => {}
+4 -4
View File
@@ -1,7 +1,7 @@
//! A picture in a visible signature, where the signer's name would be large (Acrobat: Configure
//! Signature Appearance ▸ Draw or Image, and typed signatures). Drawn as Fill & Sign draws its
//! signatures: ink smoothed and stroked with round ends, typed outlines filled even-odd, images
//! with their alpha as a soft mask.
//! signatures: ink smoothed and stroked with round ends, typed outlines filled with the non-zero
//! winding rule, images with their alpha as a soft mask.
use std::sync::Arc;
@@ -21,7 +21,7 @@ const MAX_PIXELS: u64 = 1 << 24;
pub enum Graphic {
/// Drawn strokes, y up, in units where the drawing pad is 1 wide (as Fill & Sign saves them).
Strokes(Vec<Vec<[f64; 2]>>),
/// Closed outlines, y up, filled even-odd: a typed signature in the script font.
/// Closed outlines, y up, filled with the non-zero winding rule: a typed signature in the script font.
Outlines(Vec<Vec<[f64; 2]>>),
/// Straight (not premultiplied) RGBA pixels, rows from the top.
Image { width: u32, height: u32, rgba: Arc<Vec<u8>> },
@@ -141,7 +141,7 @@ impl Graphic {
}
c.push_str("h\n");
}
c.push_str("f*\nQ\n");
c.push_str("f\nQ\n");
Ok((c, None))
}
Graphic::Image { width, height, rgba } => {
+27 -3
View File
@@ -5,9 +5,12 @@
use std::path::Path;
use std::sync::Arc;
use security_framework::certificate::SecCertificate;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult};
use security_framework::key::{Algorithm, SecKey};
use security_framework::os::macos::keychain::SecKeychain;
use security_framework::policy::SecPolicy;
use security_framework::trust::SecTrust;
use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey};
use crate::{Certificate, DigitalId, SignError};
@@ -37,15 +40,36 @@ pub fn reference(c: &Certificate) -> String {
format!("keychain:{}", d.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
/// The identity a reference (or a certificate common name) names.
/// The identity a reference (or a certificate common name) names. Its `chain` holds the
/// issuers macOS finds for the certificate, so a signature embeds them as it does for a file's.
pub fn find(reference_or_name: &str) -> Result<DigitalId, SignError> {
identities(None)?
let mut id = identities(None)?
.into_iter()
.find(|id| {
reference(&id.certificate) == reference_or_name
|| id.certificate.subject.common_name() == Some(reference_or_name.trim_start_matches("keychain:"))
})
.ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}")))
.ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}")))?;
id.chain = issuers(&id.certificate);
Ok(id)
}
/// The certificates macOS chains `certificate` to through the user's keychains (and the
/// system roots), without `certificate` itself. Best effort: an identity signs without them
/// too. Nothing is fetched from the network, so signing stays offline.
fn issuers(certificate: &Certificate) -> Vec<Certificate> {
let Ok(leaf) = SecCertificate::from_der(&certificate.raw) else { return Vec::new() };
let Ok(mut trust) = SecTrust::create_with_certificates(&[leaf], &[SecPolicy::create_x509()]) else { return Vec::new() };
if trust.set_network_fetch_allowed(false).is_err() {
return Vec::new();
}
// The chain is built even when it doesn't end at a trusted root (a private CA, an expired
// certificate); whether the signer is trusted is decided when the signature is validated.
let _ = trust.evaluate_with_error();
// `SecTrust::chain` needs macOS 12; PdfCraft runs on macOS 11 (packaging/macos/package.sh).
#[allow(deprecated)]
let chain: Vec<SecCertificate> = (0..trust.certificate_count()).filter_map(|i| trust.certificate_at_index(i)).collect();
chain.iter().map(SecCertificate::to_der).filter(|der| *der != certificate.raw).filter_map(|der| Certificate::parse(&der).ok()).collect()
}
/// The signing identities in the user's keychains (or only in the keychain file `keychain`).
+31 -4
View File
@@ -278,6 +278,12 @@ fn pss_params(params: Option<&Tlv<'_>>) -> Result<(DigestAlg, PssParams), SignEr
Ok((hash, PssParams { mgf, salt_len: Some(salt_len) }))
}
/// The length in bits of the RSA modulus `n` (big-endian; leading zero bytes don't count).
fn rsa_bits(n: &[u8]) -> usize {
let n = n.iter().position(|b| *b != 0).and_then(|i| n.get(i..)).unwrap_or_default();
n.first().map_or(0, |b| n.len() * 8 - b.leading_zeros() as usize)
}
/// A public key from a `SubjectPublicKeyInfo`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PublicKey {
@@ -380,7 +386,7 @@ impl PublicKey {
/// "RSA 2048-bit", "ECDSA P-256"…
pub fn describe(&self) -> String {
match self {
PublicKey::Rsa { n, .. } => format!("RSA {}-bit", n.len() * 8),
PublicKey::Rsa { n, .. } => format!("RSA {}-bit", rsa_bits(n)),
PublicKey::P256(_) => "ECDSA P-256".into(),
PublicKey::P384(_) => "ECDSA P-384".into(),
PublicKey::P521(_) => "ECDSA P-521".into(),
@@ -441,10 +447,31 @@ impl PublicKey {
self.verify_noting(scheme, alg, digest, sig, &mut Vec::new())
}
/// [`PublicKey::verify`], noting in `notes` each encoding irregularity it tolerated. Only
/// meaningful when it returns `Ok(true)`: a signature that doesn't verify may leave notes
/// about how far it got.
/// [`PublicKey::verify`], noting in `notes` each encoding irregularity it tolerated, and a key
/// too small to rely on ([`PublicKey::weakness`]). Only meaningful when it returns `Ok(true)`:
/// a signature that doesn't verify may leave notes about how far it got.
pub fn verify_noting(&self, scheme: Scheme, alg: DigestAlg, digest: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result<bool, SignError> {
let verified = self.verify_scheme(scheme, alg, digest, sig, notes);
if let Ok(true) = verified
&& let Some(weak) = self.weakness()
{
notes.push(weak);
}
verified
}
/// What to tell the user when a signature that still validates was made with this key, or
/// `None` for one that is fine, as [`DigestAlg::weakness`] does for hashes.
pub fn weakness(&self) -> Option<&'static str> {
match self {
PublicKey::Rsa { n, .. } if rsa_bits(n) < 2048 => Some(
"This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.",
),
_ => None,
}
}
fn verify_scheme(&self, scheme: Scheme, alg: DigestAlg, digest: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result<bool, SignError> {
match (self, scheme) {
(PublicKey::Unsupported { what, .. }, _) => Err(SignError::Unsupported(what.clone())),
(PublicKey::Rsa { n, e }, Scheme::RsaPkcs1) => crate::rsa_pad::pkcs1_verify(n, e, alg, digest, sig, notes),
+143 -40
View File
@@ -354,10 +354,14 @@ pub const WORDINGS: &[&str] = &[
"The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway.",
"The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway.",
"The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's.",
// What a signature that validates with an old hash still does not prove.
"The signature's signed attributes have no content type, which CMS requires; it was read anyway.",
// What a signature that validates with an old hash or a small key still does not prove.
"This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on.",
"This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on.",
"This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.",
// Whether the signed bytes and the signature still agree.
"The signature's signed attributes have no message digest, so the signature does not cover the document.",
"The signature's signed attributes name a content type other than data, so it is not a signature of the document.",
"The document has been altered or corrupted since the signature was applied.",
"The signer's certificate is not in the signature.",
"The signature value does not match the signer's certificate: the signature is corrupt.",
@@ -680,6 +684,19 @@ fn is_doc_timestamp(d: &Dict) -> bool {
d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && matches!(d.name(b"Type"), Some(b"DocTimeStamp" | b"Sig"))
}
/// The DocMDP permission level (`/P`, 1–3) of signature value `v`, or `None` for an approval
/// signature.
fn docmdp(doc: &Document, v: &Dict) -> Option<u8> {
v.get(b"Reference")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
.into_iter()
.flatten()
.filter_map(|r| doc.resolve(&r).as_dict().cloned())
.find(|r| r.name(b"TransformMethod") == Some(b"DocMDP"))
.map(|r| r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8)
}
fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) {
// A document timestamp can also be the value of a signature field (Documenso writes
// `Timestamp_1` that way). Its `/Contents` is a bare RFC 3161 token, not a signature over the
@@ -697,17 +714,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
info.contact = text(doc, v, b"ContactInfo");
info.sub_filter = v.name(b"SubFilter").map(|s| String::from_utf8_lossy(s).into_owned());
info.signer = text(doc, v, b"Name");
info.certify = v
.get(b"Reference")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
.into_iter()
.flatten()
.filter_map(|r| doc.resolve(&r).as_dict().cloned())
.find(|r| r.name(b"TransformMethod") == Some(b"DocMDP"))
.map(|r| {
r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8
});
info.certify = docmdp(doc, v);
let invalid = |info: &mut SignatureInfo, why: Detail| {
info.status = Status::Invalid;
info.details.push(why);
@@ -787,6 +794,36 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
}
None => cache.digest(s.digest, bytes, l0, o1, covered),
};
// With signed attributes the signature is made over them, not over the document: their
// message digest is what ties it to the signed bytes, and their content type says what those
// bytes are (data, for a document). RFC 5652 §5.3 requires both.
if s.signed_attrs.is_some() {
match s.content_type.as_deref() {
Some(crate::cms::DATA) => {}
Some(_) => {
return invalid(
info,
Detail::plain("The signature's signed attributes name a content type other than data, so it is not a signature of the document."),
);
}
None => {
info.details.push(Detail::plain("The signature's signed attributes have no content type, which CMS requires; it was read anyway."))
}
}
if s.message_digest.is_none() {
return invalid(
info,
Detail::plain("The signature's signed attributes have no message digest, so the signature does not cover the document."),
);
}
}
// A PDF signature signs data: the document, or (adbe.pkcs7.sha1) its digest. A content-type
// attribute was checked above, and `SignedData::parse` refuses one that differs from
// `eContentType`; without that attribute, `eContentType` is the only place that says so.
if sd.econtent_type != crate::cms::DATA {
let why = SignError::Malformed("CMS: the encapsulated content is not data".into());
return invalid(info, Detail::of("The signature could not be read ({}).", Fill::Reason(why.to_string())));
}
if let Some(md) = &s.message_digest
&& *md != content_digest
{
@@ -855,7 +892,18 @@ fn finish_validation(
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
Modification::None
} else {
classify_changes(doc, cache.revision(doc, bytes, covered), info.certify)
// The permissions are the signed revision's: a later one can rewrite `/P` (#1788). This
// signature is the one whose byte range ends that revision.
let old = cache.revision(doc, bytes, covered);
if let Some(old) = &old
&& let Some(v) = sig_fields(old)
.iter()
.filter_map(|f| f.signature(old))
.find(|v| nums(old, v, b"ByteRange").is_some_and(|b| b.len() == 4 && b[2] + b[3] == covered as f64))
{
info.certify = docmdp(old, &v);
}
classify_changes(doc, old, info.certify)
};
let mut problems = false;
// The changes are handed over as the words they are, not stitched into the sentence, so an
@@ -1200,6 +1248,8 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
.collect();
// The signed revision's document-level XMP stream.
let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref)));
// The signed revision's XFA data packets: filling the form rewrites them.
let old_xfa_data = xfa_data_packets(&old);
// What each object was in the signed revision: its pages, and its trailer /Info. A changed
// object is judged by the role it held when signed, never by the labels or keys its new,
// attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its
@@ -1295,6 +1345,12 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
// else, even one it already had when signed (a page's Form XObject: the
// renderer ignores /Type), does not make rewriting it a metadata change.
kind::METADATA
} else if old_xfa_data.contains(&r) {
// The form's XFA data (`datasets`) or state (`form`) as the signed revision
// named it: filling an XFA form rewrites these, as a Reader Extensions (UR3)
// save of a certified form does (#2759). The role comes from the signed
// revision, so a later one can't name page content as XFA data.
kind::FORM_FILL
} else {
kind::OTHER
}
@@ -1318,6 +1374,21 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) }
}
/// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form`
/// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one
/// XFA stream holds its template too, and is not listed.
fn xfa_data_packets(doc: &Document) -> HashSet<ObjRef> {
let Some(form) = acroform(doc) else { return HashSet::new() };
let Some(xfa) = form.get(b"XFA").map(|x| doc.resolve(x)) else { return HashSet::new() };
let Object::Array(xfa) = &*xfa else { return HashSet::new() };
xfa.as_chunks::<2>()
.0
.iter()
.filter(|[name, _]| doc.resolve(name).as_string().is_some_and(|s| matches!(s.to_text().as_str(), "datasets" | "form")))
.filter_map(|[_, packet]| packet.as_ref())
.collect()
}
/// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3).
#[derive(Clone, Copy, PartialEq, Eq)]
enum DssRole {
@@ -1605,7 +1676,8 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
// change is only a brand-new object, or one that was already a signature object when signed.
let signature_ok = before.is_none_or(is_signature_dict);
match ty {
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if signature_ok => return kind::SIGNATURE,
// Only a new signature value or policy: rewriting a signed one is not a signature (#1788).
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE,
Some(b"Catalog") => {
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"];
return match before {
@@ -2063,17 +2135,19 @@ pub fn display_date(pdf: &str) -> String {
}
/// The visible signature: the signer's name large on the left, or the graphic in its place, and
/// the details on the right (Acrobat's standard layout), in Helvetica. A graphic with no details
/// beside it fills the box. An error, before anything is drawn, when a detail it writes in
/// Helvetica (the signer's name, the certificate's subject, the reason or the location) has a
/// character Helvetica can't draw: signing invisibly, or leaving that part out, still works. A
/// graphic doesn't change this: the name it replaces on the left is still among the details.
/// A label that is drawn ([`AppearanceLabels`], the caller's words) is refused the same way.
/// the details on the right (Acrobat's standard layout), in Helvetica, or in the bundled Inter
/// font when a detail needs characters beyond Western European. A graphic with no details beside
/// it fills the box. An error, before anything is drawn, when a detail it writes (the signer's
/// name, the certificate's subject, the reason or the location) has a character neither font can
/// draw: signing invisibly, or leaving that part out, still works. A graphic doesn't change this:
/// the name it replaces on the left is still among the details. A label that is drawn
/// ([`AppearanceLabels`], the caller's words) must be drawable in Helvetica, and is refused otherwise.
fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result<Stream, SignError> {
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap};
use pdfcraft_fonts::{first_undrawable, helvetica_width, inter_width, literal, undrawable_message, win_ansi, wrap, wrap_with};
let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
let a = &opts.appearance;
let dn = cert.subject.display();
let mut needs_unicode = false;
if w > 1.0 && h > 1.0 {
let shown = [
(a.name, "the signer's name", Some(name)),
@@ -2082,11 +2156,22 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
(a.location, "the location", opts.location.as_deref()),
];
for (on, what, text) in shown {
if on && let Some(c) = text.and_then(first_undrawable) {
return Err(SignError::Unsupported(format!(
"{}; sign without a visible appearance, or leave {what} out of it",
undrawable_message(&format!("{what} of the visible signature"), c)
)));
if on
&& let Some(s) = text
&& first_undrawable(s).is_some()
{
for c in s.chars() {
if matches!(c, '\n' | '\r') {
continue;
}
if !pdfcraft_fonts::embedding::covers(pdfcraft_fonts::INTER_REGULAR, &c.to_string()) {
return Err(SignError::Unsupported(format!(
"{}; sign without a visible appearance, or leave {what} out of it",
undrawable_message(&format!("{what} of the visible signature"), c)
)));
}
}
needs_unicode = true;
}
}
// The labels are the caller's words (a translation), checked as the details are: only
@@ -2128,6 +2213,31 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
lines.retain(|l| !l.is_empty());
let mut out = Vec::new();
let mut image = None;
let mut fonts = Dict::new();
let (font_tag, metrics) = if needs_unicode {
let tag = pdfcraft_fonts::embedding::embed(doc, &mut fonts, pdfcraft_fonts::INTER_REGULAR, "Inter")
.map_err(|e| SignError::Pdf(format!("failed to embed signature font: {e}")))?;
let font_dict = fonts
.get(tag.as_bytes())
.map(|o| doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.ok_or_else(|| SignError::Pdf("embedded signature font dictionary not found".into()))?;
let m = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font_dict);
(tag, Some(m))
} else {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(b"Helv".to_vec(), Object::Dict(font));
("Helv".to_string(), None)
};
let measure = |s: &str, sz: f64| -> f64 { if needs_unicode { inter_width(s, sz) } else { helvetica_width(s, sz) } };
let wrap_text =
|s: &str, sz: f64, max_w: f64| -> Vec<String> { if needs_unicode { wrap_with(s, sz, max_w, inter_width) } else { wrap(s, sz, max_w) } };
let encode_line =
|l: &str| -> Vec<u8> { if let Some(m) = &metrics { literal(&m.encode(l).unwrap_or_else(|| win_ansi(l))) } else { literal(&win_ansi(l)) } };
if w > 1.0 && h > 1.0 {
let pad = (h * 0.06).clamp(1.0, 6.0);
let (left_w, right_x) = match (&opts.graphic, a.name) {
@@ -2146,14 +2256,14 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
// The name fills the left half.
let mut size = (h * 0.4).min(36.0);
let fit = (left_w - 2.0 * pad).max(1.0);
let longest = helvetica_width(name, 1.0).max(0.01);
let longest = measure(name, 1.0).max(0.01);
size = size.min(fit / longest).max(4.0);
let words = wrap(name, size, fit);
let words = wrap_text(name, size, fit);
let total = words.len() as f64 * size * 1.1;
let mut y = (h + total) / 2.0 - size * 0.85;
for l in &words {
out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes());
out.extend(literal(&win_ansi(l)));
out.extend(format!("/{font_tag} {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes());
out.extend(encode_line(l));
out.extend_from_slice(b" Tj\n");
y -= size * 1.1;
}
@@ -2161,7 +2271,7 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
// The details, shrunk to fit the right side.
let mut size = (h / (lines.len().max(1) as f64 * 1.15)).min(12.0);
let wrapped = loop {
let all: Vec<String> = lines.iter().flat_map(|l| wrap(l, size, right_w)).collect();
let all: Vec<String> = lines.iter().flat_map(|l| wrap_text(l, size, right_w)).collect();
if all.len() as f64 * size * 1.15 <= h - 2.0 * pad || size <= 3.0 {
break all;
}
@@ -2169,8 +2279,8 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
};
let mut y = h - pad - size * 0.9;
for l in &wrapped {
out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes());
out.extend(literal(&win_ansi(l)));
out.extend(format!("/{font_tag} {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes());
out.extend(encode_line(l));
out.extend_from_slice(b" Tj\n");
y -= size * 1.15;
}
@@ -2180,13 +2290,6 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|x| Object::Real(*x)).collect()));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
if let Some(im) = image {
+22
View File
@@ -207,6 +207,7 @@ const NTE_NOT_FOUND: u32 = 0x8009_0011;
const NTE_BAD_PROVIDER: u32 = 0x8009_0013;
const NTE_BAD_PROV_TYPE: u32 = 0x8009_0014;
const NTE_BAD_KEYSET: u32 = 0x8009_0016;
const NTE_PROVIDER_DLL_FAIL: u32 = 0x8009_001D;
const NTE_PROV_DLL_NOT_FOUND: u32 = 0x8009_001E;
const NTE_SILENT_CONTEXT: u32 = 0x8009_0022;
const NTE_NOT_SUPPORTED: u32 = 0x8009_0029;
@@ -215,6 +216,8 @@ const NTE_DEVICE_NOT_READY: u32 = 0x8009_0030;
const NTE_DEVICE_NOT_FOUND: u32 = 0x8009_0035;
const NTE_USER_CANCELLED: u32 = 0x8009_0036;
const CRYPT_E_NO_KEY_PROPERTY: u32 = 0x8009_200B;
// An NTSTATUS (ntstatus.h) that some key providers pass through unchanged.
const STATUS_NOT_FOUND: u32 = 0xC000_0225;
/// A CNG error with the Windows code and, for the usual ones, what it means.
fn describe(e: &CngError) -> String {
@@ -225,6 +228,15 @@ fn describe(e: &CngError) -> String {
NTE_BAD_PROVIDER | NTE_BAD_PROV_TYPE | NTE_PROV_DLL_NOT_FOUND => {
"its key provider isn't available to CNG (a legacy CryptoAPI-only provider, or its driver isn't installed)"
}
// #2207: a smart card or token's middleware that is missing or broken, or a provider
// that exists only as 32-bit code, which a 64-bit process can't load.
NTE_PROVIDER_DLL_FAIL => {
"its key provider's software failed to load (a smart card or token driver that is missing or broken, or a 32-bit-only provider)"
}
// #1535: an A3 certificate (key on a token) whose provider can't find the key.
STATUS_NOT_FOUND => {
"its key provider can't find the key (the smart card or token holding it isn't connected, or its driver doesn't see the key)"
}
NTE_NOT_SUPPORTED => "its key provider doesn't support the operation",
NTE_DEVICE_NOT_READY | NTE_DEVICE_NOT_FOUND => "the smart card or token isn't available",
NTE_USER_CANCELLED => "the request was cancelled",
@@ -247,6 +259,16 @@ mod tests {
"the key is missing from its key store (deleted, or imported under another Windows account) (Windows error 0x80090016)"
);
assert_eq!(describe(&CngError::WindowsError(0x8010_000C)), "the smart card isn't available (Windows error 0x8010000C)");
assert_eq!(
describe(&CngError::WindowsError(0x8009_001D)),
"its key provider's software failed to load (a smart card or token driver that is missing or broken, or a 32-bit-only provider) (Windows error 0x8009001D)",
"#2207"
);
assert_eq!(
describe(&CngError::WindowsError(0xC000_0225)),
"its key provider can't find the key (the smart card or token holding it isn't connected, or its driver doesn't see the key) (Windows error 0xC0000225)",
"#1535"
);
assert_eq!(describe(&CngError::WindowsError(0x57)), "Windows error 0x00000057");
assert_eq!(describe(&CngError::InvalidHashLength), "Invalid hash length");
}
+27 -10
View File
@@ -545,27 +545,44 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at:
}
/// Certificates from a file: DER, or PEM with one or more `CERTIFICATE` blocks (`.cer`, `.crt`,
/// `.pem`, Acrobat's `.fdf`-free exports).
/// `.pem`, Acrobat's `.fdf`-free exports), or a PKCS #7 bundle (`.p7b`, `.p7c`) in DER or as a
/// PEM `PKCS7`/`CMS` block, as Windows, Acrobat and certificate authorities export chains.
pub fn load_certificates(bytes: &[u8]) -> Result<Vec<Certificate>, SignError> {
if bytes.first() == Some(&0x30) {
return Ok(vec![Certificate::parse(bytes)?]);
// One DER certificate, else a DER bundle; a file that is neither reports the
// certificate's error, the common case.
return match Certificate::parse(bytes) {
Ok(cert) => Ok(vec![cert]),
Err(e) => crate::cms::certificate_bundle(bytes).map_err(|_| e),
};
}
let text = String::from_utf8_lossy(bytes);
let mut out = Vec::new();
let mut block: Option<String> = None;
// The block being read: its label and its base64 so far.
let mut block: Option<(&str, String)> = None;
for line in text.lines().map(str::trim) {
if line == "-----BEGIN CERTIFICATE-----" {
block = Some(String::new());
} else if line == "-----END CERTIFICATE-----" {
if let Some(b) = block.take() {
out.push(Certificate::parse(&base64(&b).ok_or_else(|| SignError::Malformed("PEM base64".into()))?)?);
if let Some(label) = line.strip_prefix("-----BEGIN ").and_then(|l| l.strip_suffix("-----")) {
block = matches!(label, "CERTIFICATE" | "PKCS7" | "CMS").then(|| (label, String::new()));
} else if let Some(label) = line.strip_prefix("-----END ").and_then(|l| l.strip_suffix("-----")) {
// An END that doesn't close the open block ends nothing.
if let Some((open, b)) = block.take_if(|(open, _)| *open == label) {
let der = base64(&b).ok_or_else(|| SignError::Malformed("PEM base64".into()))?;
if open == "CERTIFICATE" {
out.push(Certificate::parse(&der)?);
} else {
out.extend(crate::cms::certificate_bundle(&der)?);
}
}
} else if let Some(b) = block.as_mut() {
} else if let Some((_, b)) = block.as_mut() {
b.push_str(line);
}
}
// Certificates before a block that never ends are not a smaller set: the file is damaged.
if block.is_some() {
return Err(SignError::Malformed("a PEM block is not closed".into()));
}
if out.is_empty() {
return Err(SignError::Malformed("no certificate found (expected DER or PEM)".into()));
return Err(SignError::Malformed("no certificate found (expected DER, PEM or a PKCS #7 bundle)".into()));
}
Ok(out)
}
+130
View File
@@ -0,0 +1,130 @@
//! What a CMS's signed attributes may hold. RFC 5652 allows one content-type and one
//! message-digest attribute, each with one value, and the content type must be the type of the
//! encapsulated content. Both content types are object identifiers.
//!
//! These go through `SignedData::parse`, the way a PDF's signature reaches it. Each CMS is built
//! here as the signer builds it, so a case differs from a valid one only where it says.
use pdfcraft_sign::cms::SignedData;
use pdfcraft_sign::der::{self, tag};
use pdfcraft_sign::keys::DigestAlg;
use pdfcraft_sign::{SignError, pkcs12};
const SIGNED_DATA: &str = "1.2.840.113549.1.7.2";
const DATA: &str = "1.2.840.113549.1.7.1";
const TST_INFO: &str = "1.2.840.113549.1.9.16.1.4";
const CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
const MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
fn identity() -> pkcs12::DigitalId {
let p12 = std::fs::read(format!("{}/tests/data/rsa-aes.p12", env!("CARGO_MANIFEST_DIR"))).unwrap();
pkcs12::open(&p12, "test").unwrap()
}
/// A signed attribute: its type and the SET of its values.
fn attribute(oid: &str, values: &[&[u8]]) -> Vec<u8> {
der::seq(&[&der::oid(oid), &der::set_of(values)])
}
/// A SignedData whose encapsulated content has the type `encap_type` and whose one signer
/// signed the attributes `attrs`.
fn signed_data(id: &pkcs12::DigitalId, encap_type: &str, attrs: &[Vec<u8>]) -> Vec<u8> {
signed_data_with_encap(id, &der::seq(&[&der::oid(encap_type)]), attrs)
}
/// A SignedData with the encapContentInfo `encap`, as it stands, and whose one signer signed the
/// attributes `attrs`.
fn signed_data_with_encap(id: &pkcs12::DigitalId, encap: &[u8], attrs: &[Vec<u8>]) -> Vec<u8> {
let alg = DigestAlg::Sha256;
let refs: Vec<&[u8]> = attrs.iter().map(Vec::as_slice).collect();
let set = der::set_of(&refs);
let signature = id.key.sign(alg, &set).unwrap();
let mut signed_attrs = set;
signed_attrs[0] = tag::ctx(0);
let cert = &id.certificate;
let sid = der::seq(&[&cert.issuer.raw, &der::uint(&cert.serial)]);
let signer = der::seq(&[&der::int(1), &sid, &alg.algorithm(), &signed_attrs, &id.key.signature_algorithm(alg), &der::octets(&signature)]);
let signed_data =
der::seq(&[&der::int(1), &der::set_of(&[&alg.algorithm()]), encap, &der::tlv(tag::ctx(0), &cert.raw), &der::set_of(&[&signer])]);
der::seq(&[&der::oid(SIGNED_DATA), &der::explicit(0, &signed_data)])
}
/// Why `SignedData::parse` refuses `cms`.
fn refused(cms: &[u8]) -> String {
match SignedData::parse(cms) {
Err(SignError::Malformed(why)) => why,
other => panic!("expected the CMS to be refused, got {other:?}"),
}
}
#[test]
fn signed_attributes_hold_one_content_type_and_one_message_digest() {
let id = identity();
let digest = DigestAlg::Sha256.digest(&[b"document"]);
let (data, tst, md, other_md) = (der::oid(DATA), der::oid(TST_INFO), der::octets(&digest), der::octets(&[0; 32]));
let content_type = attribute(CONTENT_TYPE, &[&data]);
let message_digest = attribute(MESSAGE_DIGEST, &[&md]);
// As a signer writes them: read, and the signature over them verifies.
let sd = SignedData::parse(&signed_data(&id, DATA, &[content_type.clone(), message_digest.clone()])).unwrap();
assert_eq!(sd.signer.content_type.as_deref(), Some(DATA));
assert_eq!(sd.signer.message_digest.as_deref(), Some(digest.as_slice()));
assert!(sd.verify_signature(&id.certificate, &digest).unwrap());
// A second attribute of either kind, with the same value or another one, and an attribute
// with two values or none.
for (attrs, what) in [
(vec![content_type.clone(), content_type.clone(), message_digest.clone()], "content type"),
(vec![content_type.clone(), attribute(CONTENT_TYPE, &[&tst]), message_digest.clone()], "content type"),
(vec![attribute(CONTENT_TYPE, &[&data, &tst]), message_digest.clone()], "content type"),
(vec![attribute(CONTENT_TYPE, &[]), message_digest.clone()], "content type"),
(vec![content_type.clone(), message_digest.clone(), message_digest.clone()], "message digest"),
(vec![content_type.clone(), message_digest.clone(), attribute(MESSAGE_DIGEST, &[&other_md])], "message digest"),
(vec![content_type.clone(), attribute(MESSAGE_DIGEST, &[&md, &other_md])], "message digest"),
(vec![content_type.clone(), attribute(MESSAGE_DIGEST, &[])], "message digest"),
] {
let why = refused(&signed_data(&id, DATA, &attrs));
assert!(why.contains(&format!("one {what} with one value")), "{why}");
}
}
#[test]
fn the_signed_content_type_is_the_type_of_the_encapsulated_content() {
let id = identity();
let digest = DigestAlg::Sha256.digest(&[b"document"]);
let message_digest = attribute(MESSAGE_DIGEST, &[&der::octets(&digest)]);
let signed_as = |oid: &str| attribute(CONTENT_TYPE, &[&der::oid(oid)]);
// A PDF signature names data in both places, and an RFC 3161 token names TSTInfo in both.
SignedData::parse(&signed_data(&id, DATA, &[signed_as(DATA), message_digest.clone()])).unwrap();
SignedData::parse(&signed_data(&id, TST_INFO, &[signed_as(TST_INFO), message_digest.clone()])).unwrap();
// The signature does not cover eContentType: here it was changed from data to signedData
// (the last byte of the OID), and the signed attributes still say data.
let why = refused(&signed_data(&id, SIGNED_DATA, &[signed_as(DATA), message_digest.clone()]));
assert!(why.contains("not the encapsulated content type"), "{why}");
// Without a content-type attribute there is nothing to compare here. The type is kept, and a
// PDF signature's validation refuses one that is not data (tests/pdf.rs).
let sd = SignedData::parse(&signed_data(&id, SIGNED_DATA, std::slice::from_ref(&message_digest))).unwrap();
assert_eq!(sd.econtent_type, SIGNED_DATA);
}
#[test]
fn content_types_are_object_identifiers() {
let id = identity();
let digest = DigestAlg::Sha256.digest(&[b"document"]);
let message_digest = attribute(MESSAGE_DIGEST, &[&der::octets(&digest)]);
let content_type = attribute(CONTENT_TYPE, &[&der::oid(DATA)]);
let not_an_oid = der::octets(DATA.as_bytes());
// A content-type value that is not an OID would leave nothing to compare.
let why = refused(&signed_data(&id, DATA, &[attribute(CONTENT_TYPE, &[&not_an_oid]), message_digest.clone()]));
assert!(why.contains("expected an OID"), "{why}");
// eContentType is required (RFC 5652 §5.2) and is an OID.
let why = refused(&signed_data_with_encap(&id, &der::seq(&[&not_an_oid]), &[content_type.clone(), message_digest.clone()]));
assert!(why.contains("expected an OID"), "{why}");
let why = refused(&signed_data_with_encap(&id, &der::seq(&[]), &[content_type, message_digest]));
assert!(why.contains("eContentType"), "{why}");
}
+126
View File
@@ -239,6 +239,87 @@ fn certificates_load_from_pem_and_der_and_export_as_pem() {
assert!(pdfcraft_sign::x509::load_certificates(b"hello").is_err());
}
/// Base64 in 64-column lines, for PEM armour.
fn encode_base64(der: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in der.chunks(3) {
let n = u32::from(c[0]) << 16 | u32::from(*c.get(1).unwrap_or(&0)) << 8 | u32::from(*c.get(2).unwrap_or(&0));
for i in 0..4 {
s.push(if i <= c.len() { T[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
}
}
let lines: Vec<&str> = s.as_bytes().chunks(64).map(|l| std::str::from_utf8(l).unwrap()).collect();
lines.join("\n")
}
/// A PKCS #7 certificate bundle (degenerate SignedData, RFC 5652 §5.2) holding `certs`, laid out
/// as `openssl crl2pkcs7 -nocrl` writes it.
fn p7b(certs: &[&[u8]]) -> Vec<u8> {
use pdfcraft_sign::der::{explicit, int, oid, seq, set_of, tlv};
let encap = seq(&[&oid(cms::DATA)]);
let mut sd = vec![int(1), set_of(&[]), encap];
if !certs.is_empty() {
sd.push(tlv(0xA0, &certs.concat()));
}
sd.push(set_of(&[]));
let refs: Vec<&[u8]> = sd.iter().map(Vec::as_slice).collect();
seq(&[&oid(cms::SIGNED_DATA), &explicit(0, &seq(&refs))])
}
#[test]
fn certificates_load_from_pkcs7_bundles() {
use pdfcraft_sign::x509::{load_certificates, to_pem};
let leaf = load_certificates(&data("rsa.crt.pem")).unwrap().remove(0);
let root = load_certificates(&data("ca.crt.pem")).unwrap().remove(0);
let bundle = p7b(&[&leaf.raw, &root.raw]);
// .p7b / .p7c in DER, the way Windows and Acrobat export a chain.
assert_eq!(load_certificates(&bundle).unwrap(), vec![leaf.clone(), root.clone()]);
// The same bundle as PEM `PKCS7` and `CMS` blocks, after a key and a plain certificate.
let b64 = encode_base64(&bundle);
for label in ["PKCS7", "CMS"] {
let text = format!(
"-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n{}-----BEGIN {label}-----\n{}\n-----END {label}-----\n",
to_pem(&leaf),
b64
);
assert_eq!(load_certificates(text.as_bytes()).unwrap(), vec![leaf.clone(), leaf.clone(), root.clone()], "{label}");
}
// An END that closes a different block ends nothing.
let mismatched = format!("-----BEGIN PKCS7-----\n{b64}\n-----END CERTIFICATE-----\n");
assert!(load_certificates(mismatched.as_bytes()).is_err());
// A block that never ends is a damaged file, not a shorter list.
let unclosed = format!("{}-----BEGIN PKCS7-----\n{b64}\n", to_pem(&leaf));
assert!(load_certificates(unclosed.as_bytes()).is_err());
// A bundle with no certificate, and a SignedData that isn't one, are errors, not empty lists.
assert!(load_certificates(&p7b(&[])).is_err());
let not_signed_data = pdfcraft_sign::der::seq(&[&pdfcraft_sign::der::oid(cms::DATA), &pdfcraft_sign::der::explicit(0, &[])]);
assert!(load_certificates(&not_signed_data).is_err());
// A SignedData out of shape: no signerInfos SET, or a version that isn't an INTEGER.
{
use pdfcraft_sign::der::{explicit, int, oid, seq, set_of, tlv};
let wrap = |sd: &[&[u8]]| seq(&[&oid(cms::SIGNED_DATA), &explicit(0, &seq(sd))]);
let (certs, encap) = (tlv(0xA0, &[leaf.raw.as_slice(), &root.raw].concat()), seq(&[&oid(cms::DATA)]));
assert_eq!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs, &set_of(&[])])).unwrap().len(), 2);
assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs])).is_err());
assert!(load_certificates(&wrap(&[&set_of(&[]), &set_of(&[]), &encap, &certs, &set_of(&[])])).is_err());
// An empty encapContentInfo, a repeated [0], and [1] CRLs before [0] certificates.
let crls = tlv(0xA1, &[]);
assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &seq(&[]), &certs, &set_of(&[])])).is_err());
assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs, &certs, &set_of(&[])])).is_err());
assert!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &crls, &certs, &set_of(&[])])).is_err());
assert_eq!(load_certificates(&wrap(&[&int(1), &set_of(&[]), &encap, &certs, &crls, &set_of(&[])])).unwrap().len(), 2);
}
let mut trailing = bundle.clone();
trailing.extend_from_slice(&[0x05, 0x00]);
assert!(load_certificates(&trailing).is_err());
assert!(cms::certificate_bundle(&bundle).is_ok());
// Truncated anywhere: an error, never a panic.
for n in 0..bundle.len() {
let _ = load_certificates(&bundle[..n]);
}
}
// ── CRL and OCSP verification (RFC 5280, RFC 6960) ─────────────────────────────────────────
use pdfcraft_sign::der::{self, tag};
@@ -546,6 +627,51 @@ fn weak_digests_say_so_in_words_that_are_pinned() {
}
}
#[test]
fn small_rsa_keys_say_so_and_others_do_not() {
let rsa = |n: Vec<u8>| PublicKey::Rsa { n, e: vec![1, 0, 1] };
let note = Some(
"This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on.",
);
// 1024 bits, with and without the leading zero byte of a DER INTEGER.
assert_eq!(rsa(vec![0xC0; 128]).weakness(), note);
assert_eq!(rsa([vec![0], vec![0xC0; 128]].concat()).weakness(), note);
// 2047 bits is still short; 2048 bits and more are not.
assert_eq!(rsa([vec![0x7F], vec![0xFF; 255]].concat()).weakness(), note);
assert_eq!(rsa([vec![0, 0x80], vec![0; 255]].concat()).weakness(), None);
assert_eq!(rsa(vec![0xFF; 512]).weakness(), None);
assert_eq!(rsa(Vec::new()).weakness(), note, "an empty modulus is not a strong key");
// The key description counts the same bits, so it never says 2048 next to the warning.
assert_eq!(rsa([vec![0], vec![0xC0; 128]].concat()).describe(), "RSA 1024-bit");
assert_eq!(rsa([vec![0x7F], vec![0xFF; 255]].concat()).describe(), "RSA 2047-bit");
assert_eq!(rsa([vec![0, 0x80], vec![0; 255]].concat()).describe(), "RSA 2048-bit");
assert_eq!(PublicKey::P256(vec![4; 65]).weakness(), None);
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
assert_eq!(id.certificate.public_key.weakness(), None, "the 2048-bit test identity");
}
/// A synthetic 1024-bit key made for this test (`openssl genrsa 1024`, e = 65537) and its
/// PKCS #1 v1.5 SHA-256 signature of `hello`.
const RSA_1024_N: &str = "ae561814194f0a06475c002fc7633b81e83a738ad3f35ba1ded959be6f6b62302028d58707403f44b7bda959e446f13849896711985ac4815e58d7d323aee664771c35bf2dc3db1bcdc49ac61ae7282a24ec7a5af9f6ebe98945e6a71c4998ed31c41bbb997c4873fb4b8271fd3cf76648efcbff76972ab7bcaf831b9e203d47";
const RSA_1024_SIG: &str = "0de5110137e768a6b00201cf8340688c81a901fe309489f2c8186b7dc56e67f7e64188970a9bea926b9bd99b19985adfb0489956ffd5e0264468b678c4fd49a155a895fcb9ab127d0c3742b7a07866b3deec20a5b2f61af4cbfd2901517dc268e4d6edf4e905e0f4e05cdc740c3180971c1ec1470c1476f90ea988e2ab4cbb18";
#[test]
fn a_signature_made_with_a_small_rsa_key_verifies_with_the_note() {
use pdfcraft_sign::keys::Scheme;
let key = PublicKey::Rsa { n: unhex(RSA_1024_N), e: vec![1, 0, 1] };
let weak = key.weakness().expect("a 1024-bit key is short");
let sig = unhex(RSA_1024_SIG);
let mut notes = Vec::new();
let digest = DigestAlg::Sha256.digest(&[b"hello"]);
assert!(key.verify_noting(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &sig, &mut notes).unwrap());
assert_eq!(notes, [weak]);
// A signature that doesn't verify says nothing about the key.
let mut notes = Vec::new();
let other = DigestAlg::Sha256.digest(&[b"hullo"]);
assert!(!key.verify_noting(Scheme::RsaPkcs1, DigestAlg::Sha256, &other, &sig, &mut notes).unwrap());
assert!(!notes.contains(&weak), "{notes:?}");
}
#[test]
fn an_rsa_signature_value_not_below_the_modulus_is_refused() {
use pdfcraft_sign::keys::{DigestAlg, PssParams, Scheme};
+2
View File
@@ -11,6 +11,7 @@ The password of every `.p12` is `test`.
| `ec-legacy.p12` | `ec-p256.p12` re-exported with `-legacy` (3DES, SHA-1 MAC): the format macOS `security import` accepts |
| `ec-p384.p12` | P-384 self-signed "Test Signer P384"; AES-128-CBC, SHA-1 MAC |
| `chain.p12` | RSA 2048 "Ada Lovelace" issued by a P-256 test root, chain included |
| `chain-legacy.p12` | `chain.p12` re-exported with `-legacy`, for the Keychain tests |
| `rsa.crt.pem`, `ca.crt.pem` | the RSA certificate and the test root alone |
Regenerate with:
@@ -29,6 +30,7 @@ openssl req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj "/CN=Ad
printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,nonRepudiation\n" > leaf.ext
openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -set_serial 77 -days 3650 -extfile leaf.ext -out leaf.crt
openssl pkcs12 -export -inkey leaf.key -in leaf.crt -certfile ca.crt -out chain.p12 -passout pass:test -name "Ada Lovelace"
openssl pkcs12 -export -legacy -inkey leaf.key -in leaf.crt -certfile ca.crt -out chain-legacy.p12 -passout pass:test -name "Ada Lovelace"
```
`openssl-signed.pdf` is a hand-written one-page PDF with a `/Contents` placeholder, signed with
Binary file not shown.
+272 -4
View File
@@ -4,7 +4,8 @@ use std::sync::Arc;
use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental};
use pdfcraft_sign::{
Detail, Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures,
Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der,
in_english, kind, pkcs12, signatures,
};
fn data(name: &str) -> Vec<u8> {
@@ -13,14 +14,20 @@ fn data(name: &str) -> Vec<u8> {
/// One page with text and an unsigned signature field "Approval".
fn fixture() -> Vec<u8> {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[])
}
/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on.
fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec<u8> {
let mut objs: Vec<&[u8]> = vec![
catalog,
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>",
b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>",
];
objs.extend_from_slice(more);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
@@ -237,6 +244,23 @@ fn later_changes_are_classified_under_the_signature_permissions() {
// Certified allowing comments: fine.
let certified3 = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify: Some(3), ..opts() }).unwrap();
assert_eq!(check(&edit_after(&certified3, add_comment)).status, Status::Valid);
// #1788: a later revision relaxing the signed P 1 to P 3 does not permit the comment.
let relaxed = edit_after(&certified, |doc| {
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
let sig = doc.resolve(catalog.get(b"Perms").unwrap()).as_dict().and_then(|p| p.get(b"DocMDP").and_then(Object::as_ref)).unwrap();
doc.update_dict(sig, |v| {
if let Some(Object::Array(r)) = v.get_mut(b"Reference")
&& let Some(Object::Dict(sr)) = r.first_mut()
&& let Some(Object::Dict(tp)) = sr.get_mut(b"TransformParams")
{
tp.set(b"P".to_vec(), Object::Int(3));
}
})
.unwrap();
add_comment(doc);
});
let s = check(&relaxed);
assert_eq!((s.certify, s.status), (Some(1), Status::Invalid), "{:?}", s.modification);
}
#[test]
@@ -290,7 +314,7 @@ fn visible_signatures_show_a_drawn_typed_or_image_graphic() {
let typed = Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]], vec![[0.2, 0.1], [0.8, 0.1], [0.5, 0.4]]]);
for (graphic, drawn) in [
(strokes, " c\n"),
(typed, "f*\n"),
(typed, "f\n"),
// 40 x 10 in the left half (94 x 44 inside the padding): 94 wide, centred vertically.
(picture(true), "q 94 0 0 23.5 3 13.25 cm /Im1 Do Q"),
] {
@@ -667,6 +691,49 @@ fn signing_with_keychain_identities() {
result.unwrap();
}
/// A Keychain identity carries the certificates macOS chains it to, as a `.p12`'s does, so the
/// signature embeds its issuers. Ignored by default: the throwaway keychain joins the user's
/// keychain search list while the test runs (restored afterwards).
#[cfg(target_os = "macos")]
#[test]
#[ignore = "creates a temporary macOS keychain; run with --ignored"]
fn keychain_identities_carry_their_issuers() {
use std::process::Command;
let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-chain-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let kc = dir.join("test.keychain-db");
let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap();
let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap();
let saved: Vec<String> = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect();
let k = kc.to_str().unwrap();
assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success());
let set_list = |extra: Option<&str>| {
let mut args = vec!["list-keychains", "-d", "user", "-s"];
args.extend(saved.iter().map(String::as_str));
args.extend(extra);
sec(&args);
};
// macOS finds an identity's issuers in the keychains on the search list.
set_list(Some(k));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sec(&["unlock-keychain", "-p", "pc-test", k]);
let p = format!("{}/tests/data/chain-legacy.p12", env!("CARGO_MANIFEST_DIR"));
let out = sec(&["import", &p, "-k", k, "-P", "test", "-A"]);
assert!(out.status.success(), "chain-legacy.p12: {}", String::from_utf8_lossy(&out.stderr));
let listed = pdfcraft_sign::keychain::identities(Some(&kc)).unwrap();
assert_eq!(listed.len(), 1, "{listed:?}");
let id = pdfcraft_sign::keychain::find(&pdfcraft_sign::keychain::reference(&listed[0].certificate)).unwrap();
let issuers: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect();
assert_eq!(issuers, [Some("PrintCraft Test Root CA")]);
let file = pkcs12::open(&data("chain.p12"), "test").unwrap();
assert_eq!(id.chain.iter().map(|c| &c.raw).collect::<Vec<_>>(), file.chain.iter().map(|c| &c.raw).collect::<Vec<_>>());
}));
sec(&["delete-keychain", k]);
set_list(None);
let _ = std::fs::remove_dir_all(&dir);
result.unwrap();
}
/// `fixture()` plus a Form XObject (object 7) that the page draws.
fn fixture_with_xobject() -> Vec<u8> {
let objs: Vec<&[u8]> = vec![
@@ -749,6 +816,59 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() {
}
}
/// A form whose AcroForm carries its XFA as packets: template (7), datasets (8), form (9).
fn xfa_fixture() -> Vec<u8> {
fixture_with(
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /XFA [(template) 7 0 R (datasets) 8 0 R (form) 9 0 R] >> >>",
&[
b"<< /Length 11 >>\nstream\n<template/>\nendstream",
b"<< /Length 22 >>\nstream\n<xfa:datasets><a>1</a>\nendstream",
b"<< /Length 7 >>\nstream\n<form/>\nendstream",
],
)
}
/// Rewrite object `num` as a stream holding `body`.
fn rewrite_stream(num: u32, body: &'static [u8]) -> impl FnOnce(&mut Document) {
move |doc| doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, stream_of(body))
}
/// #2759: filling a certified XFA form rewrites its `datasets` (and `form`) packets, as a Reader
/// Extensions save of the IRCC forms does. That is form fill, which certification level 2
/// permits; level 1 still refuses it, and rewriting the template is not a form fill. The packets'
/// roles come from the signed revision: a later revision that names the template "datasets"
/// doesn't make rewriting it a form fill.
#[test]
fn rewriting_a_certified_forms_xfa_data_is_a_form_fill() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
let certified = pdfcraft_sign::sign(&open(&xfa_fixture()), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
assert_eq!(check(&certified).status, Status::Valid);
for num in [8, 9] {
let s = check(&edit_after(&certified, rewrite_stream(num, b"<xfa:datasets><a>2</a></xfa:datasets>")));
assert_eq!((s.status, s.modification.clone()), (Status::Valid, Modification::Allowed(vec![kind::FORM_FILL])), "{num}: {:?}", s.details);
}
let s = check(&edit_after(&certified, rewrite_stream(7, b"<template><subform/></template>")));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
let renamed = edit_after(&certified, |doc| {
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm") {
form.set(b"XFA".to_vec(), Object::Array(vec![Object::String(PdfString::text("datasets")), oref(7)]));
}
})
.unwrap();
rewrite_stream(7, b"<template><subform/></template>")(doc);
});
let s = check(&renamed);
assert_eq!(s.status, Status::Invalid, "{:?}", s.modification);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&kind::OTHER)), "{:?}", s.modification);
let locked = pdfcraft_sign::sign(&open(&xfa_fixture()), &id, &SignOptions { certify: Some(1), ..opts() }).unwrap();
let s = check(&edit_after(&locked, rewrite_stream(8, b"<xfa:datasets><a>2</a></xfa:datasets>")));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::FORM_FILL])));
}
/// Re-encode a DER CMS the way Windows CryptoAPI / Adobe PPKMS / `openssl cms -stream` write it:
/// indefinite lengths on ContentInfo, [0] and SignedData (the signed attributes stay DER).
fn to_ber(der_cms: &[u8]) -> Vec<u8> {
@@ -798,6 +918,105 @@ fn validates_signatures_written_with_ber_indefinite_lengths() {
}
}
/// A signed attribute: the attribute type and a SET holding its one value.
fn attribute(oid: &str, value: &[u8]) -> Vec<u8> {
der::seq(&[&der::oid(oid), &der::set_of(&[value])])
}
/// `signed` with its signature made again by `id` over the signed attributes `attrs` and its
/// encapsulated content typed `encap_type`, the rest of the CMS built as the signer builds it, so
/// only those differ.
fn resigned_with_attributes(signed: &[u8], id: &pkcs12::DigitalId, encap_type: &str, attrs: &[Vec<u8>]) -> Vec<u8> {
use pdfcraft_sign::DigestAlg;
use pdfcraft_sign::der::tag;
let alg = DigestAlg::Sha256;
let refs: Vec<&[u8]> = attrs.iter().map(Vec::as_slice).collect();
let set = der::set_of(&refs);
let signature = id.key.sign(alg, &set).unwrap();
let mut signed_attrs = set.clone();
signed_attrs[0] = tag::ctx(0);
let cert = &id.certificate;
let sid = der::seq(&[&cert.issuer.raw, &der::uint(&cert.serial)]);
let signer = der::seq(&[&der::int(1), &sid, &alg.algorithm(), &signed_attrs, &id.key.signature_algorithm(alg), &der::octets(&signature)]);
let encap = der::seq(&[&der::oid(encap_type)]);
let signed_data =
der::seq(&[&der::int(1), &der::set_of(&[&alg.algorithm()]), &encap, &der::tlv(tag::ctx(0), &cert.raw), &der::set_of(&[&signer])]);
let cms = der::seq(&[&der::oid("1.2.840.113549.1.7.2"), &der::explicit(0, &signed_data)]);
let (a, b) = contents_span(signed);
assert!(cms.len() * 2 <= b - a, "the CMS fits the /Contents placeholder");
let hex: String = cms.iter().chain(std::iter::repeat(&0)).take((b - a) / 2).map(|x| format!("{x:02x}")).collect();
let mut out = signed.to_vec();
out[a..b].copy_from_slice(hex.as_bytes());
out
}
const DATA: &str = "1.2.840.113549.1.7.1";
const TST_INFO: &str = "1.2.840.113549.1.9.16.1.4";
const CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
const MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
/// RFC 5652 §5.3: signed attributes carry the message digest, which ties the signature to the
/// document, and the content type (data, for a document). A signature over attributes without
/// the digest covers nothing of the document and is invalid; another content type is not a
/// document signature; a missing content type is read anyway, with a note. `eContentType` must
/// be data too, and the same type as the attribute, which the signature covers and it does not.
#[test]
fn signed_attributes_must_bind_the_document() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let (a, b) = contents_span(&signed);
let digest = pdfcraft_sign::DigestAlg::Sha256.digest(&[&signed[..a - 1], &signed[b + 1..]]);
let data_type = attribute(CONTENT_TYPE, &der::oid(DATA));
let tst_type = attribute(CONTENT_TYPE, &der::oid(TST_INFO));
let message_digest = attribute(MESSAGE_DIGEST, &der::octets(&digest));
let check_as = |encap_type: &str, attrs: &[Vec<u8>]| {
let pdf = resigned_with_attributes(&signed, &id, encap_type, attrs);
signatures(&open(&pdf), &pdf, &trust).into_iter().find(|s| s.signed).unwrap()
};
let check = |attrs: &[Vec<u8>]| check_as(DATA, attrs);
let said = |s: &pdfcraft_sign::SignatureInfo, sentence: &str| s.details.iter().any(|d| d.message == sentence);
let unreadable_because = |s: &pdfcraft_sign::SignatureInfo, why: &str| {
s.details.iter().any(|d| d.pattern == "The signature could not be read ({})." && d.message.contains(why))
};
// Both attributes, as a signer writes them: valid, so the cases below differ only in them.
let s = check(&[data_type.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
let s = check(std::slice::from_ref(&data_type));
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(said(&s, "The signature's signed attributes have no message digest, so the signature does not cover the document."), "{:?}", s.details);
// An RFC 3161 token's types, TSTInfo in the attribute and in eContentType.
let s = check_as(TST_INFO, &[tst_type.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(
said(&s, "The signature's signed attributes name a content type other than data, so it is not a signature of the document."),
"{:?}",
s.details
);
let s = check(std::slice::from_ref(&message_digest));
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert!(said(&s, "The signature's signed attributes have no content type, which CMS requires; it was read anyway."), "{:?}", s.details);
// A content type the attribute does not name, and one that only eContentType names.
let s = check_as(TST_INFO, &[data_type.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(unreadable_because(&s, "the signed content type is not the encapsulated content type"), "{:?}", s.details);
let s = check_as(TST_INFO, std::slice::from_ref(&message_digest));
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(unreadable_because(&s, "the encapsulated content is not data"), "{:?}", s.details);
// A second content-type attribute, with the same value or another one.
for second in [&data_type, &tst_type] {
let s = check(&[data_type.clone(), second.clone(), message_digest.clone()]);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(unreadable_because(&s, "one content type with one value"), "{:?}", s.details);
}
}
#[test]
fn validation_data_and_xmp_added_after_signing_are_not_tampering() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
@@ -1907,6 +2126,55 @@ fn appearance_labels_helvetica_cannot_draw_are_refused() {
assert!(!content.contains("Reason") && !content.contains("Date:") && !content.contains('?'), "{content}");
}
/// A visible signature with Central European characters (č, ć, ž, š) embeds the bundled
/// Inter font and draws them cleanly instead of refusing or writing `?`.
#[test]
fn visible_appearance_draws_central_european_characters_with_embedded_font() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let name = Name::build("Luka Petravić", "Oddelek", "Podjetje d.o.o.", "luka@example.com", "SI");
let cert = Certificate::self_signed(&name, &id.key, Time::from_unix(1_700_000_000), 5, &[1]).unwrap();
let luka_id = DigitalId { key: id.key, certificate: cert, chain: Vec::new(), friendly_name: None };
let shown =
pdfcraft_sign::Appearance { name: true, distinguished_name: true, reason: true, location: true, ..pdfcraft_sign::Appearance::default() };
let sl = SignOptions {
rect: Some([150.0, 20.0, 280.0, 70.0]),
reason: Some("Podpisano s potrdilom".into()),
location: Some("Ljubljana, Slovenija".into()),
appearance: shown,
..opts()
};
let signed = pdfcraft_sign::sign(&open(&fixture()), &luka_id, &sl).unwrap();
let (content, res) = appearance_of(&signed, "Signature1");
// Verify that the appearance stream does not write `?` in place of non-WinAnsi characters.
assert!(!content.contains('?'), "appearance content should not write '?' for Central European characters: {content}");
// Verify an embedded TrueType font was added to the appearance resources.
let fonts = res.get(b"Font").and_then(|o| o.as_dict()).unwrap();
assert!(fonts.contains(b"PCFull0"), "appearance should reference the embedded font: {fonts:?}");
// Verify signature validation still passes.
let doc = open(&signed);
let trust = TrustStore { certs: vec![luka_id.certificate.clone()] };
let s = signatures(&doc, &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid);
assert_eq!(s.reason.as_deref(), Some("Podpisano s potrdilom"));
}
/// A label Helvetica can't draw is refused even when a detail already needs the embedded font: the
/// labels never choose the font, so a Japanese label beside a Latin Extended name is never written
/// as the wrong glyphs.
#[test]
fn appearance_label_outside_helvetica_is_refused_beside_an_embedded_font_name() {
use pdfcraft_sign::{Appearance, AppearanceLabels};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let name = Name::build("Luka Petravić", "Oddelek", "Podjetje d.o.o.", "luka@example.com", "SI");
let cert = Certificate::self_signed(&name, &id.key, Time::from_unix(1_700_000_000), 5, &[1]).unwrap();
let luka_id = DigitalId { key: id.key, certificate: cert, chain: Vec::new(), friendly_name: None };
let japanese = AppearanceLabels { signed_by: "署名者".into(), ..AppearanceLabels::default() };
let o = SignOptions { rect: Some([150.0, 20.0, 280.0, 70.0]), appearance: Appearance::default(), appearance_labels: japanese, ..opts() };
let err = pdfcraft_sign::sign(&open(&fixture()), &luka_id, &o).unwrap_err();
assert!(matches!(err, SignError::Unsupported(_)), "{err:?}");
assert!(err.to_string().contains("the signed_by label"), "{err}");
}
/// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on
/// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the
/// sentences the modules that read a signature word for themselves are pushed as notes and quirks,
+38 -8
View File
@@ -495,7 +495,8 @@ pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage
}
let k = ((h as f64 * 0.9) / span).min((w as f64 * 0.95) / width);
let x0 = (w as f64 - width * k) / 2.0;
// Device points (y down), then an even-odd scanline fill.
// Device points (y down), then a non-zero winding scanline fill so overlapping same-direction
// TrueType contours (such as Inter's composite Ђ and ћ) stay filled where strokes cross.
let polys: Vec<Vec<(f64, f64)>> = o
.contours
.iter()
@@ -503,20 +504,29 @@ pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage
.collect();
for y in 0..h {
let sy = y as f64 + 0.5;
let mut xs: Vec<f64> = Vec::new();
let mut xs: Vec<(f64, i32)> = Vec::new();
for poly in &polys {
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
if (a.1 <= sy) != (b.1 <= sy) {
xs.push(a.0 + (sy - a.1) / (b.1 - a.1) * (b.0 - a.0));
let dir = if a.1 < b.1 { 1 } else { -1 };
xs.push((a.0 + (sy - a.1) / (b.1 - a.1) * (b.0 - a.0), dir));
}
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for pair in xs.as_chunks::<2>().0 {
let (from, to) = (pair[0].round().clamp(0.0, w as f64) as usize, pair[1].round().clamp(0.0, w as f64) as usize);
for x in from..to {
img[(x, y)] = Color32::BLACK;
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut winding = 0i32;
let mut start = 0.0;
for (x, dir) in xs {
if winding == 0 {
start = x;
}
winding += dir;
if winding == 0 {
let (from, to) = (start.round().clamp(0.0, w as f64) as usize, x.round().clamp(0.0, w as f64) as usize);
for px in from..to {
img[(px, y)] = Color32::BLACK;
}
}
}
}
@@ -1279,4 +1289,24 @@ mod tests {
}
}
}
#[test]
fn overlapping_glyph_contours_fill_without_holes_while_keeping_counters() {
let runs =
|img: &egui::ColorImage, y: usize| (0..img.width()).filter(|&x| img[(x, y)].a() > 0 && (x == 0 || img[(x - 1, y)].a() == 0)).count();
// #1786: Inter's Ђ (T + right hook) and ћ (h + crossbar) have two same-direction contours
// that overlap on the stem; non-zero winding fills the join instead of punching a hole.
let dje = super::script_preview("Ђ", 200, 200);
assert_eq!(dje[(97, 108)], egui::Color32::BLACK, "stem-hook overlap of Ђ is filled");
assert_eq!(runs(&dje, 108), 2, "stem and right loop of Ђ are two solid strokes");
let tshe = super::script_preview("ћ", 200, 200);
assert_eq!(tshe[(93, 53)], egui::Color32::BLACK, "stem-crossbar overlap of ћ is filled");
assert_eq!(runs(&tshe, 53), 1, "crossbar of ћ is one unbroken stroke across the stem");
// Counter-clockwise inner contours remain open holes.
let omicron = super::script_preview("О", 200, 200);
assert_eq!(omicron[(109, 94)].a(), 0, "counter of О stays transparent");
assert_eq!(runs(&omicron, 94), 2);
assert!(super::script_preview("", 200, 200).pixels.iter().all(|p| p.a() == 0));
assert!(super::script_preview("Ђћ", 0, 0).pixels.is_empty());
}
}
+4
View File
@@ -2336,9 +2336,13 @@ action wizard Start Iniciar
The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. La firma ECDSA no está en DER canónico (bytes de sobra, relleno o falta el byte de signo); se ha leído igualmente.
The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. La firma ECDSA son los valores r y s en bruto en vez de una secuencia DER; se ha leído igualmente.
The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. El algoritmo de la firma y el algoritmo de resumen del SignerInfo nombran hashes distintos; la firma se hizo con el del SignerInfo.
The signature's signed attributes have no content type, which CMS requires; it was read anyway. Los atributos firmados de la firma no tienen tipo de contenido, que CMS exige; se ha leído igualmente.
# What a signature that validates with an old hash still does not prove.
This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Esta firma usa SHA-1, un algoritmo de hash débil: se han demostrado colisiones en él. Sigue siendo válida porque muchos documentos firmados hace años lo usan, pero no conviene fiarse de ella.
This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Esta firma usa RIPEMD-160, un algoritmo de hash heredado de 160 bits que ya no se recomienda para firmas. Sigue siendo válida, pero no conviene fiarse de ella.
This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Esta firma usa una clave RSA de menos de 2048 bits, que ya no se considera lo bastante fuerte para firmas. Sigue siendo válida, pero no conviene fiarse de ella.
The signature's signed attributes have no message digest, so the signature does not cover the document. Los atributos firmados de la firma no tienen resumen del mensaje, así que la firma no cubre el documento.
The signature's signed attributes name a content type other than data, so it is not a signature of the document. Los atributos firmados de la firma indican un tipo de contenido distinto de datos, así que no es una firma del documento.
The document has been altered or corrupted since the signature was applied. El documento se ha alterado o se ha dañado desde que se aplicó la firma.
The signer's certificate is not in the signature. El certificado del firmante no está en la firma.
The signature value does not match the signer's certificate: the signature is corrupt. El valor de la firma no se corresponde con el certificado del firmante: la firma está dañada.
Can't render this file because it contains an unexpected character in line 6 and column 60.
+4
View File
@@ -1766,6 +1766,9 @@
The signature value does not match the signer's certificate: the signature is corrupt. Parašo reikšmė neatitinka pasirašiusiojo sertifikato: parašas pažeistas.
The signature's byte range does not exclude exactly its contents. Parašo baitų intervalas neišskiria tiksliai jo turinio.
The signature's byte range does not match the file. Parašo baitų intervalas neatitinka failo.
The signature's signed attributes have no content type, which CMS requires; it was read anyway. Parašo pasirašytuosiuose atributuose nėra turinio tipo, kurio reikalauja CMS; parašas vis tiek perskaitytas.
The signature's signed attributes have no message digest, so the signature does not cover the document. Parašo pasirašytuosiuose atributuose nėra pranešimo santraukos, todėl parašas neapima dokumento.
The signature's signed attributes name a content type other than data, so it is not a signature of the document. Parašo pasirašytuosiuose atributuose nurodytas kitas turinio tipas nei duomenys, todėl tai nėra dokumento parašas.
The signature's timestamp token could not be verified ({}). Nepavyko patvirtinti parašo laiko žymos žetono ({}).
The signature's timestamp token does not cover the signature value. Parašo laiko žymos žetonas neapima parašo reikšmės.
The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. Pasirašytas turinys yra sudėtinis (segmentuotas) OCTET STRING, kaip leidžia BER; jis vis tiek perskaitytas.
@@ -1791,6 +1794,7 @@
This document has not been modified since this timestamp was applied. Pridėjus šią laiko žymą dokumentas nepakeistas.
This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Šis parašas naudoja RIPEMD-160 — pasenusį 160 bitų maišos algoritmą, kuris parašams neberekomenduojamas. Parašas vis dar patvirtinamas, tačiau juo nereikėtų pasikliauti.
This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Šis parašas naudoja silpną SHA-1 maišos algoritmą, kuriam įrodytos kolizijos. Parašas vis dar patvirtinamas, nes SHA-1 naudoja daug prieš daugelį metų pasirašytų dokumentų, tačiau juo nereikėtų pasikliauti.
This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Šis parašas naudoja trumpesnį nei 2048 bitų RSA raktą, kuris parašams nebelaikomas pakankamai stipriu. Parašas vis dar patvirtinamas, tačiau juo nereikėtų pasikliauti.
Undo {action} Atšaukti {action}
Unsigned signature field Nepasirašytas parašo laukas
Unsigned signature field: {name} Nepasirašytas parašo laukas: {name}
Can't render this file because it contains an unexpected character in line 404 and column 9.
+4
View File
@@ -1755,6 +1755,9 @@ theme Appearance Giao diện
The signature value does not match the signer's certificate: the signature is corrupt. Giá trị chữ ký không khớp với chứng chỉ của người ký: chữ ký bị hỏng.
The signature's byte range does not exclude exactly its contents. Dải byte của chữ ký không loại trừ chính xác nội dung của nó.
The signature's byte range does not match the file. Dải byte của chữ ký không khớp với tệp.
The signature's signed attributes have no content type, which CMS requires; it was read anyway. Các thuộc tính đã ký của chữ ký không có loại nội dung, điều mà CMS yêu cầu; chữ ký vẫn được đọc.
The signature's signed attributes have no message digest, so the signature does not cover the document. Các thuộc tính đã ký của chữ ký không có giá trị băm thông điệp, nên chữ ký không bao phủ tài liệu.
The signature's signed attributes name a content type other than data, so it is not a signature of the document. Các thuộc tính đã ký của chữ ký chỉ định một loại nội dung khác với dữ liệu, nên đây không phải là chữ ký của tài liệu.
The signature's timestamp token could not be verified ({}). Không thể xác minh mã thông báo dấu thời gian của chữ ký ({}).
The signature's timestamp token does not cover the signature value. Mã thông báo dấu thời gian của chữ ký không bao phủ giá trị chữ ký.
The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. Nội dung được ký là một OCTET STRING dạng cấu trúc (phân đoạn), như BER cho phép; nó vẫn được đọc.
@@ -1780,6 +1783,7 @@ theme Appearance Giao diện
This document has not been modified since this timestamp was applied. Tài liệu này chưa bị sửa đổi kể từ khi dấu thời gian này được áp dụng.
This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Chữ ký này dùng RIPEMD-160, một thuật toán băm 160 bit cũ không còn được khuyến nghị cho chữ ký. Nó vẫn xác thực được, nhưng không nên dựa vào nó.
This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Chữ ký này dùng SHA-1, một thuật toán băm yếu: các va chạm trong nó đã được chứng minh. Nó vẫn xác thực được vì nhiều tài liệu được ký từ nhiều năm trước dùng nó, nhưng không nên dựa vào nó.
This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. Chữ ký này dùng khóa RSA ngắn hơn 2048 bit, không còn được coi là đủ mạnh cho chữ ký. Nó vẫn xác thực được, nhưng không nên dựa vào nó.
Undo {action} Hoàn tác {action}
Unsigned signature field Trường chữ ký chưa ký
Unsigned signature field: {name} Trường chữ ký chưa ký: {name}
Can't render this file because it contains an unexpected character in line 491 and column 12.
+49 -1
View File
@@ -1133,8 +1133,10 @@ pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, &
if signed.is_empty() {
return None;
}
// A valid certification heads the bar only when every signature is valid: it says nothing
// about the signatures added after it, so their problems come first.
if let Some(c) = signed.iter().find(|s| s.certify.is_some())
&& c.status == SignatureStatus::Valid
&& signed.iter().all(|s| s.status == SignatureStatus::Valid)
{
let by = c.certificate.as_ref().map(|x| {
let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default();
@@ -1662,4 +1664,50 @@ mod tests {
let nothing = Appearance { name: false, date: false, ..all };
assert!(detail_lines(&nothing, &de, "Ada", " ", "", "D").is_empty());
}
fn signature(certify: Option<u8>, status: SignatureStatus) -> SignatureInfo {
SignatureInfo {
field: "Signature".into(),
signed: true,
page: None,
rect: None,
visible: false,
signer: None,
certificate: None,
chain: Vec::new(),
date: None,
signing_time: None,
reason: None,
location: None,
contact: None,
sub_filter: None,
doc_timestamp: false,
certify,
revision: 1,
signed_len: 0,
digest: None,
algorithm: None,
timestamp: false,
timestamp_time: None,
status,
modification: sign::Modification::None,
details: Vec::new(),
}
}
/// A valid certification doesn't hide a later signature that is invalid or can't be checked:
/// the message bar names the problem, and says "Certified by" only when all are valid.
#[test]
fn the_message_bar_puts_a_signature_problem_before_the_certification() {
use SignatureStatus::{Invalid, Unknown, Valid};
let bar = |sigs: &[SignatureInfo]| banner(sigs).map(|b| (b.0, b.2));
let certified = signature(Some(2), Valid);
assert_eq!(bar(std::slice::from_ref(&certified)), Some(("badge-check", "Certified by {by}")));
assert_eq!(bar(&[certified.clone(), signature(None, Valid)]), Some(("badge-check", "Certified by {by}")));
assert_eq!(bar(&[certified.clone(), signature(None, Invalid)]), Some(("circle-x", "At least one signature is invalid.")));
assert_eq!(bar(&[certified, signature(None, Unknown)]), Some(("triangle-alert", "At least one signature has problems.")));
assert_eq!(bar(&[signature(Some(2), Unknown), signature(None, Valid)]), Some(("triangle-alert", "At least one signature has problems.")));
assert_eq!(bar(&[signature(None, Valid)]), Some(("circle-check", "Signed and all signatures are valid.")));
assert_eq!(bar(&[SignatureInfo { signed: false, ..signature(None, Invalid) }]), None);
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (XDP data row corrected; BigTIFF in Create PDF) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-19 · **Change:** patch (`.p7b`/`.p7c` bundles read for trust import) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from
its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation
@@ -56,7 +56,7 @@ features total).
| Format | Acrobat | PdfCraft | Fidelity | Tests and evidence |
|---|---|---|---|---|
| PKCS #12 digital IDs (`.p12`, `.pfx`) | R W | R W (self-signed IDs) | high | OpenSSL-made fixtures (`crates/sign/tests/data/README.md`) |
| Certificates (`.cer`, `.der`, `.pem`, `.p7c`, `.p7b`) | R W | R (`.cer/.pem/.der/.p12` trust import); W public certificate | good; `.p7c/.p7b` bundles not read | `sign.trust-import` partial, `sign.export-certificate` |
| Certificates (`.cer`, `.der`, `.pem`, `.p7c`, `.p7b`) | R W | R (`.cer/.pem/.der/.p7b/.p7c/.p12` trust import); W public certificate | good; `.p7c/.p7b` bundles read (DER or PEM), not written | `sign.trust-import` partial, `sign.export-certificate` |
| CMS / PKCS #7 signatures, RFC 3161 tokens, OCSP, CRL | R W | R W | high | `pdfsig` and OpenSSL oracles |
## Create PDF from
@@ -110,6 +110,7 @@ features total).
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | patch | Certificates row: `.p7b`/`.p7c` bundles (DER or PEM `PKCS7`/`CMS`) are read for trust import. |
| 2026-10-12 | minor | XDP split into data (read and write for XFA forms, already shipped under `form.xfa-data`) and opening `.xdp` as a document (none). Create PDF reads BigTIFF; malformed GIF headers are no longer detected as images. |
| 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. |
| 2026-10-11 | minor | Encrypted PDF row: written files open in Acrobat DC (#774, fixed by #836). FDF row: #807, #809 and #819 fixed |
+3 -3
View File
@@ -6297,7 +6297,7 @@ status = "partial"
commands = []
tools = ["sign_trust"]
evidence = ["crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
notes = "import .cer/.pem/.der/.p12 through the tool; per-certificate trust settings not yet"
notes = "import .cer/.pem/.der/.p7b/.p7c/.p12 through the tool; per-certificate trust settings not yet"
[[feature]]
id = "sign.eutl"
@@ -6344,8 +6344,8 @@ milestone = "M9"
status = "shipped"
commands = ["sign.panel"]
tools = ["sign_list"]
evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry"]
notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787)"
evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill"]
notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision"
[[feature]]
id = "sign.view-signed-version"