M9: sign and timestamp encrypted documents (#753)
* M9: sign and timestamp encrypted documents Signing a password-protected PDF failed with "not supported: signing encrypted documents". The writer encrypted every string, including the signature's /Contents placeholder, which the signer patches in place after hashing. - cos: a signature dictionary's /Contents is never encrypted or decrypted (ISO 32000-2 §7.6.2), identified by /Type /Sig|/DocTimeStamp or by /Contents + /ByteRange. Existing signatures in encrypted files now read their real CMS and survive rewrites of their dictionary. - sign: the blanket refusal is replaced by the document's permissions (Table 22) when opened with the user password: existing fields and document timestamps need fill-forms, new fields need annotate + modify, certifying needs modify. Refusals name the permissions password. - Tests for all four algorithms, counter-signing, tampering, permissions, document timestamps and the sign_document tool; parity entry sign.encrypted-documents; ROADMAP log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Hadi Chokr <hadichokr@icloud.com> * M9: signing an encrypted document waits for a pending security change to be saved Otherwise the incremental save becomes a full rewrite that applies or removes protection while signing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Signed-off-by: Hadi Chokr <hadichokr@icloud.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
7a33d4bf25
commit
49a8d04f24
|
|
@ -131,6 +131,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
|||
|
||||
## Log
|
||||
|
||||
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-10 (M9, signer certificate usage):** A trusted signer's certificate must also allow signing documents (RFC 5280 §4.2.1.3, §4.2.1.12, §4.2). A key usage without digitalSignature or nonRepudiation, an extended key usage with no document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), or a critical extension PdfCraft doesn't process makes the verdict Unknown instead of Valid, and the details say which. Certificate policies are accepted as any policy. In-process certificates test each refusal and the accepted cases; a signed PDF from a trusted TLS-server certificate was Valid before and is Unknown now. Still missing: name constraints, and unknown critical extensions on issuer certificates. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged.
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,45 @@ fn protecting_through_tools() {
|
|||
ok(&mut c, "doc_open", json!({ "path": "open.pdf" }));
|
||||
}
|
||||
|
||||
/// A form secured with "fill-sign" changes and no open password (how secured forms are usually
|
||||
/// distributed): its existing signature field can be signed without the permissions password,
|
||||
/// the update keeps the encryption, and the signature validates after reopening. A new field
|
||||
/// needs the permissions password.
|
||||
#[test]
|
||||
fn signing_an_encrypted_form_through_tools() {
|
||||
let dir = workdir("sign-encrypted");
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let field = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "signature", "rect": [20, 200, 180, 250] }))["field"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
ok(&mut a, "doc_protect", json!({ "doc": doc, "permissions_password": "boss", "changes": "fill-sign" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "form.pdf" }));
|
||||
let mut b = auto(&dir);
|
||||
let form = ok(&mut b, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut b, "doc_info", json!({ "doc": form }))["security"]["protected"], true);
|
||||
match b.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": form, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }),
|
||||
) {
|
||||
Err(ToolError::Failed(m)) => assert!(m.contains("adding new signature fields"), "{m}"),
|
||||
other => panic!("a new field needs the permissions password: {other:?}"),
|
||||
}
|
||||
let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret1", "field": field, "out": "signed.pdf" }));
|
||||
assert_eq!((r["signature"]["signer"].as_str(), r["signature"]["status"].as_str()), (Some("Ada Lovelace"), Some("unknown")));
|
||||
let original = std::fs::read(dir.join("form.pdf")).unwrap();
|
||||
let signed = std::fs::read(dir.join("signed.pdf")).unwrap();
|
||||
assert!(signed.starts_with(&original), "an incremental update");
|
||||
let mut c = auto(&dir);
|
||||
let re = ok(&mut c, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut c, "doc_info", json!({ "doc": re }))["security"]["protected"], true, "still encrypted");
|
||||
ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" }));
|
||||
let list = ok(&mut c, "sign_list", json!({ "doc": re }));
|
||||
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_opens_protected_files_with_their_passwords() {
|
||||
let dir = workdir("combine-passwords");
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
//! decrypting / encrypting the strings and streams of an object (§7.6.2).
|
||||
//!
|
||||
//! Not encrypted, per the spec: the `/Encrypt` dictionary itself, cross-reference streams, the
|
||||
//! trailer `/ID`, objects inside object streams (the object stream is encrypted as a whole), and
|
||||
//! metadata streams when `/EncryptMetadata` is false. Streams naming a crypt filter (`/Crypt` in
|
||||
//! `/Filter`) use that filter; embedded files use `/EFF`.
|
||||
//! trailer `/ID`, objects inside object streams (the object stream is encrypted as a whole),
|
||||
//! the `/Contents` of signature dictionaries, and metadata streams when `/EncryptMetadata` is
|
||||
//! false. Streams naming a crypt filter (`/Crypt` in `/Filter`) use that filter; embedded files
|
||||
//! use `/EFF`.
|
||||
|
||||
use pdfcraft_crypt::{EncryptDict, Method, SecurityHandler, StreamKind};
|
||||
|
||||
|
|
@ -81,7 +82,18 @@ fn named_crypt_filter(dict: &Dict) -> Option<Vec<u8>> {
|
|||
Some(parms.and_then(|p| p.name(b"Name").map(|n| n.to_vec())).unwrap_or_else(|| b"Identity".to_vec()))
|
||||
}
|
||||
|
||||
/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object.
|
||||
/// A signature dictionary (field signature, document timestamp or usage-rights signature),
|
||||
/// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a
|
||||
/// placeholder, hashes the file around it and patches the value in place, so encrypting it
|
||||
/// would make signing impossible. Identified as other readers do: by `/Type`, or by a string
|
||||
/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional).
|
||||
fn is_signature_dict(d: &Dict) -> bool {
|
||||
matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp"))
|
||||
|| (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))))
|
||||
}
|
||||
|
||||
/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object,
|
||||
/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]).
|
||||
pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object {
|
||||
let strings = |s: &PdfString| {
|
||||
let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) };
|
||||
|
|
@ -94,6 +106,14 @@ fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandl
|
|||
match o {
|
||||
Object::String(s) => Object::String(strings(s)),
|
||||
Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()),
|
||||
Object::Dict(d) if is_signature_dict(d) => Object::Dict(
|
||||
d.iter()
|
||||
.map(|(k, v)| match (k.as_slice(), v) {
|
||||
(b"Contents", Object::String(_)) => (k.clone(), v.clone()),
|
||||
_ => (k.clone(), walk(v, strings, h, num, generation, decrypt)),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()),
|
||||
Object::Stream(s) => {
|
||||
let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect();
|
||||
|
|
|
|||
|
|
@ -195,3 +195,57 @@ fn qpdf_agrees() {
|
|||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place
|
||||
/// after hashing the file around it. Its other strings, and the `/Contents` of anything that
|
||||
/// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched.
|
||||
#[test]
|
||||
fn signature_contents_stay_unencrypted() {
|
||||
for alg in ALL {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
let hex = |b: &[u8]| Object::String(PdfString { bytes: b.to_vec(), hex: true });
|
||||
let byte_range = Object::Array([0, 10, 20, 30].into_iter().map(Object::Int).collect());
|
||||
let mut sig = pdfcraft_cos::Dict::new();
|
||||
sig.set(b"Type".to_vec(), Object::name("Sig"));
|
||||
sig.set(b"Contents".to_vec(), hex(&[0x30, 0x82, 0xDE, 0xAD]));
|
||||
sig.set(b"ByteRange".to_vec(), byte_range.clone());
|
||||
sig.set(b"Name".to_vec(), PdfString::literal(b"Signer Name".to_vec()));
|
||||
let sig = doc.add(Object::Dict(sig));
|
||||
// No /Type: still a signature dictionary by its /Contents and /ByteRange.
|
||||
let mut untyped = pdfcraft_cos::Dict::new();
|
||||
untyped.set(b"Contents".to_vec(), hex(&[0x30, 0x82, 0xBE, 0xEF]));
|
||||
untyped.set(b"ByteRange".to_vec(), byte_range);
|
||||
let untyped = doc.add(Object::Dict(untyped));
|
||||
let mut note = pdfcraft_cos::Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec()));
|
||||
let note = doc.add(Object::Dict(note));
|
||||
doc.update_dict(doc.root().unwrap(), |c| {
|
||||
c.set(b"Test".to_vec(), Object::Array(vec![Object::Ref(sig), Object::Ref(untyped), Object::Ref(note)]))
|
||||
})
|
||||
.unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
let raw = String::from_utf8_lossy(&bytes);
|
||||
assert!(raw.contains("<3082DEAD>") && raw.contains("<3082BEEF>"), "{alg:?}: signature /Contents written as is");
|
||||
assert!(!raw.contains("Signer Name") && !raw.contains("Private note"), "{alg:?}: other strings are encrypted");
|
||||
let back = Document::open(Arc::new(bytes)).unwrap();
|
||||
let test = back.get(back.root().unwrap()).as_dict().unwrap().get(b"Test").unwrap().as_array().unwrap().clone();
|
||||
let string = |i: usize, key: &[u8]| {
|
||||
let o = back.resolve(&test[i]);
|
||||
o.as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone()
|
||||
};
|
||||
assert_eq!(string(0, b"Contents"), [0x30, 0x82, 0xDE, 0xAD], "{alg:?}: read back without decrypting");
|
||||
assert_eq!(string(0, b"Name"), b"Signer Name", "{alg:?}");
|
||||
assert_eq!(string(1, b"Contents"), [0x30, 0x82, 0xBE, 0xEF], "{alg:?}");
|
||||
assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3001,7 +3001,7 @@ impl Session {
|
|||
/// `opts.date` takes the session clock.
|
||||
pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let d = self.get(doc).ok_or(EditError::NoDocument)?;
|
||||
// (Encrypted documents are refused by the signer for now.)
|
||||
// Encrypted documents are signed under their permissions (the signer checks them).
|
||||
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
|
||||
if opts.date.is_empty() {
|
||||
opts.date = self.signing_date();
|
||||
|
|
|
|||
|
|
@ -1314,6 +1314,33 @@ pub struct SignOptions {
|
|||
pub appearance: Appearance,
|
||||
}
|
||||
|
||||
/// What an encrypted document's permissions (ISO 32000-2 §7.6.4.2, Table 22) allow when it was
|
||||
/// opened with the user password (the owner may do anything): signing an existing, empty
|
||||
/// signature field is filling in a form field (bit 9, or bit 6); creating a new signature
|
||||
/// field needs bits 6 and 4; certifying sets the catalog's `/Perms`, a document change (bit 4).
|
||||
/// The signature's `/Contents` stays unencrypted (`pdfcraft-cos` exempts it, §7.6.2), so the
|
||||
/// placeholder can be patched after hashing exactly as in an unencrypted file.
|
||||
fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), SignError> {
|
||||
let Some(p) = doc.permissions() else { return Ok(()) };
|
||||
let refuse = |what: &str| {
|
||||
Err(SignError::Pdf(format!(
|
||||
"the document's security settings don't allow {what}; open it with the permissions password, or sign an existing signature field"
|
||||
)))
|
||||
};
|
||||
if opts.field.is_some() && !p.fill_forms() {
|
||||
return Err(SignError::Pdf(
|
||||
"the document's security settings don't allow filling in form fields or signing; open it with the permissions password to sign it".into(),
|
||||
));
|
||||
}
|
||||
if opts.field.is_none() && !(p.annotate() && p.modify()) {
|
||||
return refuse("adding new signature fields");
|
||||
}
|
||||
if opts.certify.is_some() && !p.modify() {
|
||||
return refuse("certifying it");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placeholder `/ByteRange` values: fixed width, patched after writing.
|
||||
const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333];
|
||||
|
||||
|
|
@ -1344,9 +1371,12 @@ fn sign_inner(
|
|||
opts: &SignOptions,
|
||||
tsa: Option<&dyn crate::timestamp::TimestampAuthority>,
|
||||
) -> Result<Vec<u8>, SignError> {
|
||||
if doc.security().is_some() || doc.output_handler().is_some() {
|
||||
return Err(SignError::Unsupported("signing encrypted documents".into()));
|
||||
// A pending security change would make the incremental save a full rewrite that applies
|
||||
// or removes protection as it signs (and breaks existing signatures): save it first.
|
||||
if doc.encryption_changed() {
|
||||
return Err(SignError::Pdf("save the document's new security settings before signing it".into()));
|
||||
}
|
||||
check_signing_permissions(doc, opts)?;
|
||||
let mut doc = doc.clone();
|
||||
let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?;
|
||||
let alg = id.key.preferred_digest();
|
||||
|
|
@ -1519,8 +1549,15 @@ fn sign_inner(
|
|||
/// whose `/Contents` is an RFC 3161 token covering the whole current file (`/ETSI.RFC3161`).
|
||||
/// The transport is the caller's; a rejected or malformed token produces no file.
|
||||
pub fn timestamp_document(doc: &Document, tsa: &dyn crate::timestamp::TimestampAuthority, date: &str) -> Result<Vec<u8>, SignError> {
|
||||
if doc.security().is_some() || doc.output_handler().is_some() {
|
||||
return Err(SignError::Unsupported("timestamping encrypted documents".into()));
|
||||
// A pending security change would make the incremental save a full rewrite that applies
|
||||
// or removes protection as it signs (and breaks existing signatures): save it first.
|
||||
if doc.encryption_changed() {
|
||||
return Err(SignError::Pdf("save the document's new security settings before signing it".into()));
|
||||
}
|
||||
if doc.permissions().is_some_and(|p| !p.fill_forms()) {
|
||||
return Err(SignError::Pdf(
|
||||
"the document's security settings don't allow signing; open it with the permissions password to timestamp it".into(),
|
||||
));
|
||||
}
|
||||
let mut doc = doc.clone();
|
||||
let mut v = Dict::new();
|
||||
|
|
|
|||
|
|
@ -1356,3 +1356,138 @@ fn validates_the_legacy_adbe_x509_rsa_sha1_format() {
|
|||
let s = signatures(&open(&bad), &bad, &trusted).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
|
||||
}
|
||||
|
||||
/// `fixture()` protected with an empty user password (opens without asking, like most
|
||||
/// "secured" forms) and an owner password, then saved.
|
||||
fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec<u8> {
|
||||
let mut doc = open(&fixture());
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions,
|
||||
encrypt_metadata: true,
|
||||
seed: [17; 32],
|
||||
})
|
||||
.unwrap();
|
||||
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn open_pw(b: &[u8], pw: Option<&str>) -> Document {
|
||||
Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap()
|
||||
}
|
||||
|
||||
/// Filling in forms and signing existing fields only (bits 3, 7–10): what Acrobat's "Filling
|
||||
/// in form fields, and signing existing signature fields" writes.
|
||||
const FILL_AND_SIGN_ONLY: i32 = -3132;
|
||||
|
||||
const ALL_ALGORITHMS: [pdfcraft_cos::Algorithm; 4] =
|
||||
[pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256];
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_are_signed_with_an_unencrypted_contents() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let id2 = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone(), id2.certificate.clone()] };
|
||||
for alg in ALL_ALGORITHMS {
|
||||
let base = protected(alg, -1);
|
||||
let doc = open_pw(&base, None);
|
||||
assert_eq!(doc.security().unwrap().auth(), pdfcraft_cos::Auth::User);
|
||||
let signed = pdfcraft_sign::sign(&doc, &id, &opts()).unwrap();
|
||||
assert!(signed.starts_with(&base), "{alg:?}: an incremental update");
|
||||
let added = String::from_utf8_lossy(&signed[base.len()..]);
|
||||
assert!(added.contains("/Encrypt") && added.contains("/ID"), "{alg:?}: the update keeps the file's security");
|
||||
assert!(!added.contains("I approve") && !added.contains("London"), "{alg:?}: the new strings are encrypted");
|
||||
assert!(added.contains("/Contents <30"), "{alg:?}: the CMS is written in the clear");
|
||||
let reopened = open_pw(&signed, None);
|
||||
assert!(reopened.security().is_some());
|
||||
let sigs = signatures(&reopened, &signed, &trust);
|
||||
let s = sigs.iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{alg:?}: {:?}", s.details);
|
||||
assert_eq!(s.modification, Modification::None);
|
||||
assert_eq!((s.reason.as_deref(), s.location.as_deref()), (Some("I approve this document"), Some("London")), "{alg:?}");
|
||||
assert!(s.visible && s.revision == 2 && s.signed_len == signed.len());
|
||||
// The parsed /Contents is the CMS itself, not "decrypted" noise.
|
||||
let cms = reopened
|
||||
.scan_objects()
|
||||
.filter_map(|(_, o)| o.as_dict().filter(|d| d.name(b"Type") == Some(b"Sig")).and_then(|d| d.get(b"Contents").cloned()))
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(cms.as_string().unwrap().bytes.first(), Some(&0x30), "{alg:?}");
|
||||
// Counter-signing the existing field keeps the first signature valid.
|
||||
let both = pdfcraft_sign::sign(&reopened, &id2, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
|
||||
let sigs = signatures(&open_pw(&both, None), &both, &trust);
|
||||
let first = sigs.iter().find(|s| s.field == "Signature1").unwrap();
|
||||
assert_eq!(first.status, Status::Valid, "{alg:?}: {:?}", first.details);
|
||||
assert_eq!(first.modification, Modification::Allowed(vec!["signature".into()]), "{alg:?}: {:?}", first.details);
|
||||
let second = sigs.iter().find(|s| s.field == "Approval").unwrap();
|
||||
assert_eq!((second.status, second.revision), (Status::Valid, 3), "{alg:?}: {:?}", second.details);
|
||||
// The page text is still encrypted, and tampering with its ciphertext breaks both.
|
||||
assert!(!String::from_utf8_lossy(&both).contains("Contract text"));
|
||||
let mut tampered = both.clone();
|
||||
let at = base.len() / 2;
|
||||
tampered[at] ^= 1;
|
||||
let tampered_sigs = signatures(&open_pw(&tampered, None), &tampered, &trust);
|
||||
assert!(tampered_sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Invalid), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_are_signed_only_as_their_permissions_allow() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let base = protected(pdfcraft_cos::Algorithm::Aes256, FILL_AND_SIGN_ONLY);
|
||||
let user = open_pw(&base, None);
|
||||
let refused = |r: Result<Vec<u8>, SignError>, what: &str| match r {
|
||||
Err(SignError::Pdf(m)) => assert!(m.contains(what) && m.contains("permissions password"), "{m}"),
|
||||
other => panic!("expected a refusal, got {:?}", other.map(|b| b.len())),
|
||||
};
|
||||
// A new field is a form change the settings don't allow.
|
||||
refused(pdfcraft_sign::sign(&user, &id, &opts()), "adding new signature fields");
|
||||
// So is certifying, even in an existing field.
|
||||
refused(pdfcraft_sign::sign(&user, &id, &SignOptions { field: Some("Approval".into()), certify: Some(2), ..opts() }), "certifying");
|
||||
// Signing the existing field is allowed.
|
||||
let signed = pdfcraft_sign::sign(&user, &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
|
||||
let s = signatures(&open_pw(&signed, None), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!((s.field.as_str(), s.status), ("Approval", Status::Valid), "{:?}", s.details);
|
||||
// Settings that allow no form filling refuse even that (print only).
|
||||
let print_only = open_pw(&protected(pdfcraft_cos::Algorithm::Aes256, -3900), None);
|
||||
refused(pdfcraft_sign::sign(&print_only, &id, &SignOptions { field: Some("Approval".into()), ..opts() }), "signing");
|
||||
// The owner may do anything: a new, certifying signature.
|
||||
let owner = open_pw(&base, Some("owner"));
|
||||
let certified = pdfcraft_sign::sign(&owner, &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
|
||||
let s = signatures(&open_pw(&certified, None), &certified, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
assert_eq!(s.certify, Some(2), "{:?}", s.details);
|
||||
}
|
||||
|
||||
/// A security change that hasn't been saved would turn the signing save into a full rewrite that
|
||||
/// applies or removes protection: signing refuses until it is saved.
|
||||
#[test]
|
||||
fn signing_waits_for_a_pending_security_change_to_be_saved() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let mut owner = open_pw(&protected(pdfcraft_cos::Algorithm::Aes256, FILL_AND_SIGN_ONLY), Some("owner"));
|
||||
owner.remove_encryption();
|
||||
match pdfcraft_sign::sign(&owner, &id, &opts()) {
|
||||
Err(SignError::Pdf(m)) => assert!(m.contains("save the document's new security settings"), "{m}"),
|
||||
other => panic!("expected a refusal, got {:?}", other.map(|b| b.len())),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_take_a_document_timestamp() {
|
||||
let tsa = TestTsa {
|
||||
id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(),
|
||||
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
|
||||
};
|
||||
let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY);
|
||||
let stamped = pdfcraft_sign::timestamp_document(&open_pw(&base, None), &tsa, "D:20261006120000Z").unwrap();
|
||||
assert!(stamped.starts_with(&base));
|
||||
let s = signatures(&open_pw(&stamped, None), &stamped, &TrustStore { certs: vec![tsa.id.certificate.clone()] })
|
||||
.into_iter()
|
||||
.find(|s| s.doc_timestamp)
|
||||
.unwrap();
|
||||
assert_eq!((s.status, s.timestamp_time), (Status::Valid, Some(tsa.time)), "{:?}", s.details);
|
||||
let print_only = open_pw(&protected(pdfcraft_cos::Algorithm::Aes128, -3900), None);
|
||||
assert!(matches!(pdfcraft_sign::timestamp_document(&print_only, &tsa, "D:20261006120000Z"), Err(SignError::Pdf(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5825,6 +5825,18 @@ tools = ["sign_windows_ids", "sign_document"]
|
|||
evidence = ["crates/sign/tests/pdf.rs::signing_with_windows_store_identities", "crates/sign/tests/pdf.rs::a_windows_store_identity_signs_with_its_issuers", "crates/sign/tests/pdf.rs::windows_store_enumeration_and_missing_identity", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/ui-egui/tests/signing.rs::windows_store_identity_does_not_ask_for_a_file_password"]
|
||||
notes = "Current User Personal (My) CNG identities: RSA PKCS #1 v1.5 and DER-encoded ECDSA P-256/P-384. Keys never leave Windows; windows:<fingerprint|common name> for agents and Windows store labels in the desktop ID picker. The signature embeds the issuers Windows chains the certificate to. Tested with throwaway software-backed RSA-2048, P-256 and P-384 certificates (run with --ignored). Smart cards and tokens whose driver registers a CNG key storage provider sign with the provider's own PIN dialog (checked by hand with a SafeNet token only; no hardware in CI); direct PKCS #11 still planned."
|
||||
|
||||
[[feature]]
|
||||
id = "sign.encrypted-documents"
|
||||
title = "Sign and timestamp password-protected (encrypted) documents"
|
||||
area = "H"
|
||||
tier = "P0"
|
||||
milestone = "M9"
|
||||
status = "shipped"
|
||||
commands = ["sign.digital", "sign.certify"]
|
||||
tools = ["sign_document"]
|
||||
evidence = ["crates/sign/tests/pdf.rs::encrypted_documents_are_signed_with_an_unencrypted_contents", "crates/sign/tests/pdf.rs::encrypted_documents_are_signed_only_as_their_permissions_allow", "crates/sign/tests/pdf.rs::encrypted_documents_take_a_document_timestamp", "crates/cos/tests/encryption.rs::signature_contents_stay_unencrypted", "crates/automation/tests/automation.rs::signing_an_encrypted_form_through_tools"]
|
||||
notes = "RC4-40/128, AES-128 and AES-256 Standard security; the incremental update keeps /Encrypt and /ID, encrypts the new strings and streams, and leaves the signature /Contents unencrypted (§7.6.2), which reading also honours. Opened with the user password, permissions (Table 22) apply: existing signature fields need fill-forms, new fields need annotate + modify, certifying needs modify, a document timestamp needs fill-forms; the owner may do anything. Not yet: certificate (public-key) security, which PdfCraft can't open; checked only against PdfCraft's own validator, not yet against an external one (pyHanko) or Acrobat"
|
||||
|
||||
[[feature]]
|
||||
id = "sign.pkcs11"
|
||||
title = "Sign with PKCS#11 tokens and smart cards"
|
||||
|
|
|
|||
Loading…
Reference in New Issue