fix(sign): refuse to sign a field whose /V is not a signature (#1328) (#1402)

Follow-up to #1374. Signing an existing field refused a field whose /V
is a signature dictionary and signed one whose /V is missing or null,
but a /V that is neither (an empty string, a number, a name, an array)
was treated as unsigned and overwritten with the new signature.

Signing such a field by name now fails with "the value of the field
... is not a signature" and leaves the document as it is. A missing or
null /V, direct or indirect, still signs, and a signed field is still
refused as already signed. The signature list is unchanged: it still
shows such a field as unsigned, since it carries no signature.

Adds a_field_whose_value_is_not_a_signature_is_refused_by_name, which
fails before this change.
This commit is contained in:
Marco Arnulfo authored and GitHub committed 2026-10-12 07:52:34 -04:00
1 parent 6f49967459
commit 452ab51ab8
2 files changed
+39

No files matched your search

+9
View File
@@ -491,6 +491,12 @@ impl Field {
fn signature(&self, doc: &Document) -> Option<Dict> {
self.dict.get(b"V").map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned())
}
/// True when /V holds something that is neither null nor a signature dictionary: signing the
/// field would overwrite a value we can't read as a signature.
fn has_foreign_value(&self, doc: &Document) -> bool {
self.dict.get(b"V").map(|v| doc.resolve(v)).is_some_and(|v| !matches!(*v, Object::Null) && v.as_dict().is_none())
}
}
/// Every signature field, depth first in `/Fields` order.
@@ -1722,6 +1728,9 @@ fn sign_inner(
if f.signature(&doc).is_some() {
return Err(SignError::Pdf(format!("the field {:?} is already signed", f.name)));
}
if f.has_foreign_value(&doc) {
return Err(SignError::Pdf(format!("the value of the field {:?} is not a signature", f.name)));
}
let fr = f.r.ok_or_else(|| SignError::Pdf("the field is not an indirect object".into()))?;
doc.update_dict(fr, |d| d.set(b"V".to_vec(), Object::Ref(sig)))?;
let (wr, wd) = f.widgets.first().cloned().ok_or_else(|| SignError::Pdf("the field has no widget".into()))?;
+30
View File
@@ -156,6 +156,36 @@ fn a_field_with_a_null_value_is_unsigned_and_can_be_signed() {
}
}
/// Follow-up to #1374: a signature field whose /V is neither null nor a signature dictionary is
/// refused by name with an error saying so, instead of being overwritten or called signed.
#[test]
fn a_field_whose_value_is_not_a_signature_is_refused_by_name() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 };
let values = [
("empty string", Object::String(PdfString::literal(""))),
("number", Object::Int(5)),
("name", Object::name("Off")),
("array", Object::Array(Vec::new())),
];
for (what, value) in values {
let direct = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"V".to_vec(), value.clone())).unwrap());
let indirect = edit_after(&fixture(), |doc| {
let v = doc.add(value.clone());
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(v))).unwrap();
});
for (how, bytes) in [("direct", direct), ("indirect", indirect)] {
let doc = open(&bytes);
let listed = signatures(&doc, &bytes, &TrustStore::default());
assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed), "{how} {what}");
match pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }) {
Err(SignError::Pdf(e)) => assert_eq!(e, "the value of the field \"Approval\" is not a signature", "{how} {what}"),
other => panic!("{how} {what}: {:?}", other.map(|b| b.len())),
}
}
}
}
/// Append a revision that changes object `num` with `f`.
fn edit_after(signed: &[u8], f: impl FnOnce(&mut Document)) -> Vec<u8> {
let mut doc = open(signed);