diff --git a/SIGNATURE_PORT.md b/SIGNATURE_PORT.md new file mode 100644 index 00000000..f8d54eae --- /dev/null +++ b/SIGNATURE_PORT.md @@ -0,0 +1,29 @@ +# Digital-signature port (vksp2k7/pdfcraft `feat/upstream-signature` -> pdfcraft-android) + +Android already had the digital-ID flow (Fill & Sign > "Sign with a digital ID": create or import +a .p12, sign, validate, trust a signer). This port brings in the upstream branch's newer signing +code underneath it. + +## Changed +- `crates/sign/**`: replaced with the upstream branch's version (hardened PKCS #12 open/write, + validated-signature appearances, stricter ExtendedKeyUsage, new tests). +- `crates/cos/src/document.rs`: native credential reuse for encrypted earlier revisions, xref + oddity tracking, and exact-entry checks used by signature validation. +- `crates/engine/src/lib.rs`: `display_bytes` now draws validated signature appearances; signatures + are validated before the display copy is built; trust changes redraw the page and increment the + document generation. +- Android UI refreshes the document after trust changes so Compose/page-image caches request the + new validated appearance. +- `crates/automation/src/{signing,tools}.rs`: `sign_document` takes `appearance` and `labels`; + password minimum is `pkcs12::MIN_PASSWORD_CHARS` (8). +- Android UI (`DigitalSign.kt`): Create-ID password minimum 6 -> 8 (`MIN_ID_PASSWORD`). +- Tests: passwords `secret1` -> `secret12`; the three upstream xref/signature regression tests run + normally now that the cos hardening is native. + +## Verification +The Rust cos encryption, xref, and signing regression tests pass. The full workspace checks and +Android release build are run separately as part of the delivery checklist. + +## Note +New IDs now use 600,000 PBKDF2 rounds (upstream's `WRITE_ROUNDS`), which is noticeably slower on a +phone when creating or opening an ID than the old 2048. diff --git a/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/AppViewModel.kt b/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/AppViewModel.kt index 9539e077..90f5575f 100644 --- a/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/AppViewModel.kt +++ b/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/AppViewModel.kt @@ -1206,6 +1206,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } fun signFieldWithId(doc: OpenDocument, w: io.github.artcraftandroid.pdfcraft.core.WidgetInfo) { + if (!doc.info.readOnlyReason.isNullOrEmpty()) { + tell("This document can't be changed, so it can't be signed.") + return + } openCertSign(io.github.artcraftandroid.pdfcraft.ui.viewer.CertTarget(doc, w.field, w.page.toInt(), null)) } @@ -1411,6 +1415,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { private suspend fun applyTrust(eng: Engine) { val paths = withContext(Dispatchers.IO) { trustedCerts.files().map { it.absolutePath } } eng.tool("sign_trust", mapOf("clear" to true, "paths" to paths)) + // The engine redraws trusted signature appearances under a new generation; refresh the + // document so Compose/page caches request the new pixels and signature state. + document?.let { refresh(it) } } // ---- Edit PDF ---------------------------------------------------------------------------- @@ -1733,11 +1740,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { "checkbox" -> fillField(doc, w.field, !w.on) "radio" -> w.export?.let { fillField(doc, w.field, it) } "text", "combo", "list" -> fieldEdit = io.github.artcraftandroid.pdfcraft.ui.viewer.FieldEdit(doc, w) - // a signed field shows its signature's details; an empty one, a drawn or a digital signature + // A signed field shows its signature's details; an empty field opens digital-ID + // signing. Handwritten Fill & Sign remains available from the Fill & Sign toolbar, + // rather than silently turning a certificate tap into a scribble pad. "signature" -> if (doc.signatures.any { it.field == w.field && it.signed }) { doc.controller.sheet = io.github.artcraftandroid.pdfcraft.ui.viewer.ViewerSheet.Signatures } else { - signFieldChoice = doc to w + signFieldWithId(doc, w) } "button" -> pressButton(doc, w) else -> return false diff --git a/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/viewer/DigitalSign.kt b/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/viewer/DigitalSign.kt index 15685830..57b9a32f 100644 --- a/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/viewer/DigitalSign.kt +++ b/apps/pdfcraft-android/android/app/src/main/java/io/github/artcraftandroid/pdfcraft/ui/viewer/DigitalSign.kt @@ -72,6 +72,9 @@ import org.json.JSONObject // here), and what the signatures in a document say. The engine signs (PAdES, incrementally) and // validates; these are the screens around it. +/** The shortest password a new digital ID may have; the engine enforces the same minimum (`pkcs12::MIN_PASSWORD_CHARS`). */ +const val MIN_ID_PASSWORD = 8 + /** A signer's certificate, as a signature's details show it. */ data class SignerCert(val name: String, val issuer: String, val validTo: String?, val sha256: String, val selfSigned: Boolean) @@ -418,7 +421,7 @@ fun CreateIdScreen(vm: AppViewModel) { again.isNotEmpty() && again != password -> stringResource(R.string.sign_password_mismatch) else -> null } - val ok = name.isNotBlank() && password.length >= 6 && again == password + val ok = name.isNotBlank() && password.length >= MIN_ID_PASSWORD && again == password Column(Modifier.fillMaxSize().background(c.surface).pointerInput(Unit) { detectTapGestures() }.safeTopPadding().navigationBarsPadding().imePadding()) { Row(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = { vm.idCreate = false }) { Text(stringResource(R.string.common_cancel), color = c.accent) } diff --git a/apps/pdfcraft-android/src/tests.rs b/apps/pdfcraft-android/src/tests.rs index d625ecf7..47324400 100644 --- a/apps/pdfcraft-android/src/tests.rs +++ b/apps/pdfcraft-android/src/tests.rs @@ -370,10 +370,10 @@ fn signs_with_a_digital_id_and_trusts_its_signer() { let s = MobileSession::new(); let dir = scratch(); let id = dir.join("me.p12").to_string_lossy().into_owned(); - let made = json!({ "name": "Test Signer", "email": "signer@example.com", "password": "secret1", "path": id }); + let made = json!({ "name": "Test Signer", "email": "signer@example.com", "password": "secret12", "path": id }); s.execute("sign_id_create".into(), made.to_string()).unwrap(); // the file's certificate, and a wrong password told apart - let info = digital_id_info(id.clone(), "secret1".into()).unwrap(); + let info = digital_id_info(id.clone(), "secret12".into()).unwrap(); assert_eq!(info.name, "Test Signer"); assert_eq!(info.email.as_deref(), Some("signer@example.com")); assert!(info.self_signed && info.valid_to > info.valid_from && info.problem.is_none()); @@ -388,7 +388,7 @@ fn signs_with_a_digital_id_and_trusts_its_signer() { let doc = s.open(path.clone(), None).unwrap().id; assert!(s.signer_certificate(doc, "Signature1".into()).unwrap().is_none()); let args = - json!({ "doc": doc, "id": id, "password": "secret1", "page": 1, "rect": [72, 72, 252, 122], "graphic": { "text": "Test" }, "out": path }); + json!({ "doc": doc, "id": id, "password": "secret12", "page": 1, "rect": [72, 72, 252, 122], "graphic": { "text": "Test" }, "out": path }); let out: Value = serde_json::from_str(&s.execute("sign_document".into(), args.to_string()).unwrap()).unwrap(); let field = out["signature"]["field"].as_str().unwrap().to_string(); assert_eq!(out["signature"]["status"], "unknown"); diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 2d0281fc..94fc4bde 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -17,6 +17,62 @@ fn time(secs: i64) -> sign::Time { sign::Time::from_unix(secs) } +/// The longest label `sign_document` takes (characters): a label is a few words. +const MAX_LABEL_CHARS: usize = 200; + +/// `appearance`: which items the visible signature shows; those not given keep the default +/// (name, date and labels on; reason, location and distinguished name off). +fn appearance_arg(v: &Value) -> Result { + const KEYS: &str = "name, date, reason, location, distinguished_name, labels"; + let o = v.as_object().ok_or_else(|| bad(format!("appearance must be an object of true/false items ({KEYS})")))?; + let mut shown = sign::Appearance::default(); + for (key, value) in o { + let on = value.as_bool().ok_or_else(|| bad(format!("appearance.{key} must be true or false")))?; + let item = match key.as_str() { + "name" => &mut shown.name, + "date" => &mut shown.date, + "reason" => &mut shown.reason, + "location" => &mut shown.location, + "distinguished_name" => &mut shown.distinguished_name, + "labels" => &mut shown.labels, + other => return Err(bad(format!("unknown appearance item {other:?} ({KEYS})"))), + }; + *item = on; + } + Ok(shown) +} + +/// `labels`: the words of the visible signature's labels; those not given keep the English. Each +/// must be drawable in the signature's font. Nobody sees a preview here, so a label it can't draw +/// is refused even when this signature doesn't show it. +fn labels_arg(v: &Value) -> Result { + const KEYS: &str = "signed_by, distinguished_name, reason, location, date"; + let o = v.as_object().ok_or_else(|| bad(format!("labels must be an object of strings ({KEYS})")))?; + let mut labels = sign::AppearanceLabels::default(); + for (key, value) in o { + let text = value.as_str().ok_or_else(|| bad(format!("labels.{key} must be a string")))?; + if text.chars().count() > MAX_LABEL_CHARS { + return Err(bad(format!("labels.{key} is longer than {MAX_LABEL_CHARS} characters"))); + } + let label = match key.as_str() { + "signed_by" => &mut labels.signed_by, + "distinguished_name" => &mut labels.distinguished_name, + "reason" => &mut labels.reason, + "location" => &mut labels.location, + "date" => &mut labels.date, + other => return Err(bad(format!("unknown label {other:?} ({KEYS})"))), + }; + *label = text.to_string(); + } + if let Some((field, c)) = labels.first_undrawable() { + return Err(bad(format!( + "labels.{field}: {}", + pdfcraft_engine::undrawable_message(&format!("the {field} label of the visible signature"), c) + ))); + } + Ok(labels) +} + pub(crate) fn cert_json(c: &Certificate) -> Value { json!({ "name": c.display_name(), @@ -89,8 +145,8 @@ impl Automation { return Err(bad("name must not be empty")); } let password = a.str("password")?; - if password.chars().count() < 6 { - return Err(bad("the password must have at least 6 characters")); + if password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS { + return Err(bad(format!("the password must have at least {} characters", sign::pkcs12::MIN_PASSWORD_CHARS))); } let path = self.resolve(a.str("path")?, true)?; let key = match a.opt_str("key")?.unwrap_or("rsa2048") { @@ -177,6 +233,17 @@ impl Automation { Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))), Some(g) => Some(self.signature_graphic(g)?), }; + let visible = rect.is_some() || field.is_some(); + let appearance = match a.get("appearance") { + None | Some(Value::Null) => sign::Appearance::default(), + Some(_) if !visible => return Err(bad("appearance needs a visible signature: pass rect or field")), + Some(v) => appearance_arg(v)?, + }; + let appearance_labels = match a.get("labels") { + None | Some(Value::Null) => sign::AppearanceLabels::default(), + Some(_) if !visible => return Err(bad("labels needs a visible signature: pass rect or field")), + Some(v) => labels_arg(v)?, + }; let out = self.resolve(a.str("out")?, true)?; let opts = SignOptions { field, @@ -186,6 +253,8 @@ impl Automation { location: a.opt_str("location")?.map(str::to_string), contact: a.opt_str("contact")?.map(str::to_string), certify, + appearance, + appearance_labels, graphic, ..SignOptions::default() }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d9e07dbc..79564ac2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -658,7 +658,7 @@ pub fn tools() -> Vec { "country": { "type": "string", "description": "Two-letter country code." }, "key": { "type": "string", "enum": ["rsa2048", "rsa3072", "rsa4096", "p256"] }, "years": { "type": "integer", "minimum": 1, "maximum": 50 }, - "password": { "type": "string", "minLength": 6 }, + "password": { "type": "string", "minLength": pdfcraft_engine::sign::pkcs12::MIN_PASSWORD_CHARS }, "path": { "type": "string", "description": "The .p12 file to write." }, }), &["name", "password", "path"], @@ -671,7 +671,7 @@ pub fn tools() -> Vec { .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), - t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") .cmd("sign.digital") .with(schema( json!({ @@ -696,6 +696,31 @@ pub fn tools() -> Vec { "minProperties": 1, "maxProperties": 1, }, + "appearance": { + "type": "object", + "description": "What a visible signature shows; items not given keep their default.", + "properties": { + "name": { "type": "boolean", "description": "Default true." }, + "date": { "type": "boolean", "description": "Default true." }, + "reason": { "type": "boolean", "description": "Default false." }, + "location": { "type": "boolean", "description": "Default false." }, + "distinguished_name": { "type": "boolean", "description": "Default false." }, + "labels": { "type": "boolean", "description": "Default true." }, + }, + "additionalProperties": false, + }, + "labels": { + "type": "object", + "description": "The words of a visible signature's labels (at most 200 characters each, Western European characters only); labels not given stay English.", + "properties": { + "signed_by": { "type": "string", "description": "Above the name. Default \"Digitally signed by\"." }, + "distinguished_name": { "type": "string", "description": "Before the certificate's subject. Default \"DN: \"." }, + "reason": { "type": "string", "description": "Default \"Reason: \"." }, + "location": { "type": "string", "description": "Default \"Location: \"." }, + "date": { "type": "string", "description": "Default \"Date: \"." }, + }, + "additionalProperties": false, + }, "out": { "type": "string", "description": "Where to save the signed document." }, }), &["doc", "id", "out"], diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0edad36e..20bda729 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1330,7 +1330,7 @@ fn protecting_through_tools() { 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" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "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() @@ -1343,12 +1343,12 @@ fn signing_an_encrypted_form_through_tools() { 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" }), + &json!({ "doc": form, "id": "ada.p12", "password": "secret12", "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" })); + let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret12", "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(); @@ -1356,7 +1356,7 @@ fn signing_an_encrypted_form_through_tools() { 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" })); + ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); 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}"); } @@ -3513,12 +3513,12 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { std::fs::write(dir.join("logo.png"), png).unwrap(); std::fs::write(dir.join("broken.png"), b"broken").unwrap(); let mut a = auto(&dir); - ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| { a.call( "sign_document", - &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }), + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 1, "rect": rect, "graphic": graphic, "out": out }), ) }; for (graphic, why) in [ @@ -3538,7 +3538,7 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { // A missing file fails as it does for every tool that reads one. assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_)))); // An invisible signature shows nothing, so a graphic for one is a mistake. - let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" })); + let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "graphic": { "text": "Ada" }, "out": "bad.pdf" })); assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}"); assert!(!dir.join("bad.pdf").exists()); @@ -3574,7 +3574,7 @@ fn digital_ids_signing_and_validation_through_tools() { let id = ok( &mut a, "sign_id_create", - json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }), + json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, E=ada@example.com"); assert_eq!(id["certificate"]["self_signed"], true); @@ -3601,7 +3601,7 @@ fn digital_ids_signing_and_validation_through_tools() { let r = ok( &mut a, "sign_document", - json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), + json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); assert_eq!(r["signature"]["status"], "unknown"); assert_eq!(r["signature"]["signer"], "Ada Lovelace"); @@ -3611,7 +3611,7 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" })); + let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); let list = ok(&mut a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e844876a..462cebad 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -105,6 +105,8 @@ pub struct Document { entries: Arc>, trailer: Dict, revisions: Arc>, + /// Cross-reference entries the reader set aside; see [`Self::xref_oddities`]. + xref_oddities: Arc>, repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, @@ -241,6 +243,13 @@ fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { } } +/// How an encrypted document is opened: with a password, or with the file key of a document +/// that has the same encryption (an earlier revision of the same file). +enum Credentials<'a> { + Password(Option<&'a str>), + Handler { handler: &'a Arc, id0: Vec }, +} + impl std::fmt::Debug for Document { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Document").field("bytes", &self.data.len()).field("objects", &self.entries.len()).field("edits", &self.overlay.len()).finish() @@ -257,6 +266,7 @@ impl Document { entries: Arc::new(BTreeMap::new()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), + xref_oddities: Arc::default(), repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), @@ -302,6 +312,21 @@ impl Document { /// document is read-only: objects in a larger object stream read as missing, so the /// writers refuse to save it ([`CosError::ReadOnlyLimit`]). pub fn open_with_stream_limit(data: Arc>, password: Option<&str>, stream_limit: usize) -> Result { + Self::open_as(data, Credentials::Password(password), stream_limit) + } + /// Open `data`, an earlier revision of this file (the bytes a signature covers) or another + /// save of it, with this document's credentials: an encrypted one must have the same + /// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no + /// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data + /// encrypted differently fails with `NeedsPassword`. + pub fn open_with_same_credentials(&self, data: Arc>) -> Result { + let credentials = match &self.security { + Some(handler) => Credentials::Handler { handler, id0: self.first_id() }, + None => Credentials::Password(None), + }; + Self::open_as(data, credentials, self.stream_limit) + } + fn open_as(data: Arc>, credentials: Credentials<'_>, stream_limit: usize) -> Result { // Viewers accept files whose header is missing or damaged as long as the body looks // like PDF; so do we (a note goes to the repair log). let header = find(&data, b"%PDF-", 0, 1024); @@ -320,6 +345,7 @@ impl Document { entries: Arc::new(BTreeMap::new()), trailer: Dict::new(), revisions: Arc::new(Vec::new()), + xref_oddities: Arc::default(), repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), @@ -342,14 +368,15 @@ impl Document { let parsed = doc.read_xref_chain(&mut log); let mut authenticated = false; let ok = match parsed { - Ok((entries, trailer, revisions)) => { + Ok((entries, trailer, revisions, oddities)) => { doc.entries = Arc::new(entries); doc.trailer = trailer; doc.revisions = Arc::new(revisions); + doc.xref_oddities = Arc::new(oddities); // Authenticate before checking the catalog: it may sit in an encrypted object // stream, which reads as garbage until the key is known. if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, password)?; + doc.authenticate(&enc, &credentials)?; authenticated = true; } doc.root_is_catalog() @@ -370,7 +397,7 @@ impl Document { if (!ok || !authenticated) && let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, password)?; + doc.authenticate(&enc, &credentials)?; } let max = doc.entries.keys().next_back().copied().unwrap_or(0); let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32; @@ -378,7 +405,7 @@ impl Document { Ok(doc) } - fn authenticate(&mut self, enc: &Object, password: Option<&str>) -> Result<(), CosError> { + fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> { // Authentication can change how the same input is decoded. self.source_identity = Arc::new(()); let (num, dict) = match enc { @@ -396,12 +423,16 @@ impl Document { }; let params = crate::security::encrypt_dict(self, &dict); let id0 = self.first_id(); - let handler = pdfcraft_crypt::SecurityHandler::open(params, &id0, password).map_err(|e| match e { - pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, - pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, - other => CosError::Security(other.to_string()), - })?; - self.security = Some(Arc::new(handler)); + let handler = match credentials { + Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { + pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, + pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, + other => CosError::Security(other.to_string()), + })?), + Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler), + Credentials::Handler { .. } => return Err(CosError::NeedsPassword), + }; + self.security = Some(handler); self.encrypt_num = num; // Objects read while locating the catalog were read before decryption was possible. self.cache = Arc::default(); @@ -624,6 +655,23 @@ impl Document { pub fn xref_entry(&self, num: u32) -> Option { self.entries.get(&num).cloned() } + /// Cross-reference entries this reader did not take at face value. + pub fn xref_oddities(&self) -> &[(u64, u32)] { + &self.xref_oddities + } + /// Whether the stored cross-reference entry locates the object itself, rather than relying on a search fallback. + pub fn xref_entry_is_exact(&self, num: u32) -> bool { + match self.xref_entry(num) { + None | Some(XrefEntry::Free { .. }) => true, + Some(XrefEntry::InFile { offset, .. }) => { + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); + usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) + } + Some(XrefEntry::InStream { stream, index }) => { + self.objstm(stream).is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) + } + } + } /// Object `num` has an unsaved edit. pub fn is_edited(&self, num: u32) -> bool { @@ -1020,7 +1068,7 @@ impl Document { } #[allow(clippy::type_complexity)] - fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec), CosError> { + fn read_xref_chain(&self, log: &mut Vec) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; @@ -1032,6 +1080,7 @@ impl Document { let mut entries = BTreeMap::new(); let mut trailer: Option = None; let mut revisions = Vec::new(); + let mut oddities = Vec::new(); let mut visited = HashSet::new(); let mut next = Some(first); while let Some(off) = next.take() { @@ -1041,12 +1090,14 @@ impl Document { } // This section's own entries; older sections only fill what newer ones left out. let mut sect = BTreeMap::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect) { + let mut odd = Vec::new(); + let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { Ok(v) => v, Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); - self.read_section(off as usize + self.header_offset, &mut sect)? + odd.clear(); + self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? } Err(e) => { if revisions.is_empty() { @@ -1061,17 +1112,22 @@ impl Document { // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm) { + match self.read_section(x as usize, &mut stm, &mut odd) { Ok(_) => { for (num, e) in stm { - if sect.get(&num).is_none_or(|old| matches!(old, XrefEntry::Free { .. })) { - sect.insert(num, e); + match sect.get(&num) { + None | Some(XrefEntry::Free { .. }) => { + sect.insert(num, e); + } + Some(old) if *old != e => odd.push(num), + Some(_) => {} } } } Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")), } } + oddities.extend(odd.into_iter().map(|num| (off, num))); for (num, e) in sect { entries.entry(num).or_insert(e); } @@ -1085,11 +1141,11 @@ impl Document { for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { trailer.remove(k); } - Ok((entries, trailer, revisions)) + Ok((entries, trailer, revisions, oddities)) } /// Read one section at `off`, adding entries not already known (newer sections win). - fn read_section(&self, off: usize, entries: &mut BTreeMap) -> Result<(Dict, bool), CosError> { + fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1123,9 +1179,14 @@ impl Document { }; // Offset 0 "in use" entries are a known producer bug: ignore them. if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { + if num != 0 { + odd.push(num); + } continue; } - entries.entry(num).or_insert(entry); + if *entries.entry(num).or_insert(entry) != entry { + odd.push(num); + } } } } @@ -1179,12 +1240,20 @@ impl Document { 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, - _ => continue, // reserved types are treated as null references (§7.5.8.3) + _ => { + odd.push(num); + continue; + } }; if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { + if num != 0 { + odd.push(num); + } continue; } - entries.entry(num).or_insert(entry); + if *entries.entry(num).or_insert(entry) != entry { + odd.push(num); + } } } Ok((s.dict.clone(), true)) @@ -1740,4 +1809,32 @@ mod tests { }); rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } + #[test] + fn cross_reference_entries_that_are_set_aside_are_recorded() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let plain = Document::open(Arc::new(base.clone())).unwrap(); + assert!(plain.xref_oddities().is_empty()); + assert!((1..=3).all(|n| plain.xref_entry_is_exact(n))); + let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap(); + let third = plain.xref_entry(3).unwrap(); + let mut bytes = base.clone(); + let section = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]); + assert_eq!(doc.xref_entry(3), Some(third)); + assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 })); + } + + #[test] + fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let text = String::from_utf8_lossy(&base).into_owned(); + let at = text.find("3 0 obj").unwrap(); + let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec())); + assert!(!doc.xref_entry_is_exact(3)); + assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); + } } diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index 6868a6dc..a00ace89 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -279,3 +279,35 @@ fn signature_contents_stay_unencrypted() { assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}"); } } + +#[test] +fn an_earlier_revision_opens_with_the_documents_own_credentials() { + for alg in ALL { + let original = protect(alg, "user", "owner", -1); + let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised")))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let current = Document::open_with_password(Arc::new(saved), Some("user")).unwrap(); + assert!(matches!(Document::open(Arc::new(original.clone())), Err(CosError::NeedsPassword)), "{alg:?}"); + let earlier = current.open_with_same_credentials(Arc::new(original.clone())).unwrap(); + assert_eq!(content(&earlier), SECRET, "{alg:?}"); + assert_eq!(title(&earlier).as_deref(), Some("Quarterly numbers"), "{alg:?}"); + let other = { + let mut other = Document::open(Arc::new(fixture())).unwrap(); + other + .set_encryption(&NewEncryption { + algorithm: alg, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + write_full(&other, &SaveOptions::default()).unwrap() + }; + assert!(matches!(current.open_with_same_credentials(Arc::new(other)), Err(CosError::NeedsPassword)), "{alg:?}"); + assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 658adaab..f3613c34 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -77,7 +77,7 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline}; +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -701,12 +701,15 @@ fn is_placeholder_title(title: &str) -> bool { } /// What is displayed: the working file, plus (in memory only, never saved) the appearances -/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]). +/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]) and +/// the validated layered signature appearances ([`pdfcraft_sign::pdf::with_validated_appearances`]). /// The working file is unchanged, and so is everything that reads it (saving, signatures, …). -fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { - let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else { - return bytes.clone(); - }; +fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>, signatures: &[SignatureInfo]) -> Arc> { + let Some(e) = editor else { return bytes.clone() }; + let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); + let base = comments.as_ref().unwrap_or(&e.cos); + let sigs = pdfcraft_sign::pdf::with_validated_appearances(base, signatures); + let Some(display) = sigs.or(comments) else { return bytes.clone() }; // Writing fails only as saving the document would; the page then shows as it always did. write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone()) } @@ -3005,7 +3008,9 @@ impl Session { Ok(Err(e)) => (None, Some(e.to_string())), Err(_) => (None, Some("the document structure could not be read for editing".into())), }; - let display = display_bytes(editor.as_mut(), &bytes); + let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default(); + let display = display_bytes(editor.as_mut(), &bytes, &signatures); let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); let (form, adopted) = editor .as_ref() @@ -3034,8 +3039,6 @@ impl Session { }) .unwrap_or_default(); let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default(); - let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default(); self.next_id += 1; let id = DocId(self.next_id); self.docs.push(Document { @@ -3345,7 +3348,7 @@ impl Session { if !doc.signatures.is_empty() { doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); } - doc.display = display_bytes(Some(editor), &bytes); + doc.display = display_bytes(Some(editor), &bytes, &doc.signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; Ok(()) @@ -3398,7 +3401,7 @@ impl Session { if !doc.signatures.is_empty() { doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); } - doc.display = display_bytes(Some(editor), &bytes); + doc.display = display_bytes(Some(editor), &bytes, &doc.signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; doc.fonts_stale = true; @@ -3443,7 +3446,7 @@ impl Session { doc.added = marker_scan.added; doc.links = pdfcraft_annot::links::list(&editor.cos); doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); + doc.display = display_bytes(Some(editor), &bytes, &doc.signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; Ok(()) @@ -4101,6 +4104,14 @@ impl Session { if let Some(e) = doc.editor.as_ref() { doc.signatures = signatures_of(&e.cos, &doc.bytes, &doc.trust, &doc.sig_cache); } + // The validated-signature appearance follows trust, so the page is redrawn too. + if !doc.signatures.is_empty() && doc.editor.is_some() { + let bytes = doc.bytes.clone(); + doc.display = display_bytes(doc.editor.as_mut(), &bytes, &doc.signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + // Page caches key on the generation; without a new one, old bitmaps can retain the question mark. + doc.generation += 1; + } } } diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 866994d7..c0bc15d6 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -135,9 +135,8 @@ impl DigestCache { #[cfg(test)] self.discovery_scans.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut complete = true; - // A timestamp has a /ByteRange: only objects whose text says so are parsed. - let mut candidates: Vec<_> = doc - .scan_objects_mentioning(b"ByteRange") + let candidates: Vec<_> = doc + .scan_objects_checked() .filter_map(|(reference, object)| match object { Ok(object) => is_document_timestamp(&object).then_some(reference), Err(_) => { @@ -146,8 +145,6 @@ impl DigestCache { } }) .collect(); - // the scan reads object streams together, not in number order - candidates.sort_unstable_by_key(|r| r.num); if eligible && complete && candidates.len() <= DISCOVERY_CACHE_LIMIT @@ -159,13 +156,15 @@ impl DigestCache { candidates } - /// The signed revision `bytes[..end]`, parsed (and kept). - fn revision(&self, bytes: &[u8], end: usize) -> Option { + /// The signed revision `bytes[..end]`, parsed (and kept). An encrypted one is opened with + /// `doc`'s own key (the same `/Encrypt` and `/ID`), so a user password the document was + /// opened with is neither needed again nor kept here (#1808). + fn revision(&self, doc: &Document, bytes: &[u8], end: usize) -> Option { let key = (end, fingerprint(&bytes[..end])); if let Some(d) = self.revisions.lock().ok().and_then(|m| m.get(&key).cloned()) { return Some(d); } - let d = Document::open(Arc::new(bytes[..end].to_vec())).ok()?; + let d = doc.open_with_same_credentials(Arc::new(bytes[..end].to_vec())).ok()?; if let Ok(mut m) = self.revisions.lock() { if m.len() > 8 { m.clear(); @@ -469,7 +468,7 @@ impl SignatureInfo { } } -fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option { +pub(crate) fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option { let o = doc.resolve(d.get(key)?); let s = match &*o { Object::String(s) => s.to_text(), @@ -488,6 +487,20 @@ struct Field { widgets: Vec<(Option, Dict)>, } +impl Field { + /// The signature dictionary in /V. A field without one is unsigned, whether /V is missing, + /// null or not a dictionary. + fn signature(&self, doc: &Document) -> Option { + 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. fn sig_fields(doc: &Document) -> Vec { let mut out = Vec::new(); @@ -569,7 +582,7 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig .filter(|r| r.len() == 4) .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5); - let v = f.dict.get(b"V").map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()); + let v = f.signature(doc); let mut info = SignatureInfo { field: f.name.clone(), signed: v.is_some(), @@ -715,7 +728,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf let covered = o1 + l1; info.signed_len = covered; // The signed revision's number: its cross-reference sections (1 for a reconstructed file). - info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1)); + info.revision = cache.revision(doc, bytes, covered).map_or(1, |d| d.revisions().len().max(1)); // What can't be checked is unknown, not invalid: only data that is wrong or fails its check // is "invalid". let unsupported = |info: &mut SignatureInfo, why: &str| { @@ -842,7 +855,7 @@ 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(bytes, covered), info.certify) + classify_changes(doc, cache.revision(doc, bytes, covered), info.certify) }; let mut problems = false; // The changes are handed over as the words they are, not stitched into the sentence, so an @@ -1004,7 +1017,7 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig }; let covered = o1 + l1; info.signed_len = covered; - info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1)); + info.revision = cache.revision(doc, bytes, covered).map_or(1, |d| d.revisions().len().max(1)); let token = match crate::timestamp::parse_token(&contents) { Ok(t) => t, Err(e) => return invalid(info, Detail::of("The timestamp token could not be read ({}).", Fill::Reason(e.to_string()))), @@ -1027,7 +1040,7 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig 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(bytes, covered), None) + classify_changes(doc, cache.revision(doc, bytes, covered), None) }; match &info.modification { Modification::None => info.details.push(Detail::plain("This document has not been modified since this timestamp was applied.")), @@ -1192,6 +1205,8 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its // /Type and adding /ModDate, must not reclassify it away from page content). let signed_pages: HashSet = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect(); + // What each signed page draws from, by the role it held when signed (#1787). + let signed_resources = signed_page_resources(&old, &signed_pages); let old_info = old.trailer().get(b"Info").and_then(Object::as_ref); let (mut allowed, mut disallowed): (Vec<&'static str>, Vec<&'static str>) = (Vec::new(), Vec::new()); let dss = dss_parts(doc, &old); @@ -1209,6 +1224,18 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod _ => false, } }; + // A later cross-reference section may carry an entry this reader sets aside (an in-use entry + // at offset 0, a second entry for one object, a reserved entry type). The renderer may follow + // it and draw another object than the one compared below, so the page could show new content + // while every comparison here finds the signed object untouched. No writer has a reason to + // add such an entry after signing: it is never a permitted change. + for (_, num) in doc.xref_oddities().iter().filter(|o| !old.xref_oddities().contains(o)) { + let r = ObjRef { num: *num, generation: doc.generation(*num) }; + let what = if old_content.contains(&r) || signed_pages.contains(&r) { kind::PAGE_CONTENT } else { kind::OTHER }; + if !disallowed.contains(&what) { + disallowed.push(what); + } + } for num in doc.object_numbers() { if same_place(num) { continue; @@ -1217,12 +1244,31 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod let new = doc.get(r); let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null)); if before.as_deref() == Some(&*new) { + // The same content at a new place. That is true for the renderer as well only when + // the entry locates the object itself: where this reader had to search for the + // object, another reader may arrive at a different one. + if !doc.is_edited(num) && !doc.xref_entry_is_exact(num) && !disallowed.contains(&kind::OTHER) { + disallowed.push(kind::OTHER); + } continue; } - let what = match &*new { + // A resource of a signed page is page content whatever keys its new version carries + // (#1787): a /FT or /Subtype /Widget written by whoever rewrote a font, an XObject or a + // graphics state does not make that a form fill. A container (the resources dictionary, + // a category dictionary) that kept every entry it had and only gained names changes + // nothing the signed content draws, and is judged as before. + let kept_entries = |d: &Dict| before.as_deref().and_then(Object::as_dict).is_some_and(|b| b.iter().all(|(k, v)| d.get(k) == Some(v))); + let resource_kind = match (signed_resources.get(&r), &*new) { + (Some(false), _) => Some(kind::PAGE_CONTENT), + (Some(true), Object::Dict(d)) if kept_entries(d) => None, + (Some(true), _) => Some(kind::PAGE_CONTENT), + (None, _) => None, + }; + let what = match (resource_kind, &*new) { + (Some(k), _) => k, // The Document Security Store's own arrays and dictionaries, when they only grew. - _ if dss.contains(&num) => kind::SECURITY_STORE, - Object::Dict(d) => { + (_, _) if dss.contains(&num) => kind::SECURITY_STORE, + (_, Object::Dict(d)) => { let was = before.as_deref().and_then(Object::as_dict); if signed_pages.contains(&r) { // A signed page is always judged by what the page shows, whatever it is now @@ -1235,7 +1281,7 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod change_kind(doc, &old, d, was) } } - Object::Stream(s) => { + (_, Object::Stream(s)) => { // File plumbing is what the stream was when signed: the /Type it has now is // written by whoever changed it, and the renderer ignores /Type (a page's Form // XObject relabelled /ObjStm still draws its new content). @@ -1456,6 +1502,88 @@ fn is_signature_dict(d: &Dict) -> bool { matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams")) || d.name(b"FT") == Some(b"Sig") } +/// The objects the signed pages draw from, by reference, with the role each held when signed +/// (#1787): `true` for a container (a page's resources dictionary, or one of its category +/// dictionaries), where a name added later can't be reached by unchanged content; `false` for +/// everything reached from a container's entries (fonts and their files, XObjects and their own +/// resources, graphics states and soft masks, colour spaces, patterns, shadings, optional-content +/// properties), where any change changes what the page shows. Resources are the page's own or +/// inherited. Bounded by depth and by the number of objects visited; past the bound the rest is +/// judged as before. +fn signed_page_resources(old: &Document, pages: &HashSet) -> HashMap { + const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; + const MAX_VISITED: usize = 50_000; + const MAX_DEPTH: u8 = 32; + const MAX_INHERITANCE: usize = 64; + + /// Everything reachable from `o` is content the page draws (a leaf role, overriding a + /// container one reached by another path). A signed page or the catalog reached this way + /// (a `/P` or `/Parent` back-reference) keeps the role of its own: the walk stops there. + fn reach(old: &Document, o: &Object, depth: u8, stop: &HashSet, roles: &mut HashMap) { + if roles.len() >= MAX_VISITED || depth > MAX_DEPTH { + return; + } + let resolved; + let o = match o { + Object::Ref(r) => { + if stop.contains(r) || roles.insert(*r, false).is_some() { + return; + } + resolved = old.get(*r); + &*resolved + } + other => other, + }; + match o { + Object::Dict(d) => d.iter().for_each(|(_, v)| reach(old, v, depth + 1, stop, roles)), + Object::Stream(s) => s.dict.iter().for_each(|(_, v)| reach(old, v, depth + 1, stop, roles)), + Object::Array(a) => a.iter().for_each(|v| reach(old, v, depth + 1, stop, roles)), + _ => {} + } + } + + /// A page's `/Resources`, its own or the nearest ancestor's. + fn resources(old: &Document, page: ObjRef) -> Option { + let mut seen = HashSet::new(); + let mut cur = page; + while seen.insert(cur) && seen.len() <= MAX_INHERITANCE { + let d = old.get(cur); + let d = d.as_dict()?; + if let Some(r) = d.get(b"Resources") { + return Some(r.clone()); + } + cur = d.get(b"Parent").and_then(Object::as_ref)?; + } + None + } + + let mut roles = HashMap::new(); + let mut stop = pages.clone(); + stop.extend(old.root()); + for page in pages { + let Some(res) = resources(old, *page) else { continue }; + let container = |o: &Object, roles: &mut HashMap| -> Arc { + match o { + Object::Ref(r) => { + roles.entry(*r).or_insert(true); + old.get(*r) + } + other => Arc::new(other.clone()), + } + }; + let res = container(&res, &mut roles); + let Some(d) = res.as_dict() else { continue }; + for category in CATEGORIES { + let Some(v) = d.get(category) else { continue }; + let category = container(v, &mut roles); + if let Some(entries) = category.as_dict() { + entries.iter().for_each(|(_, entry)| reach(old, entry, 0, &stop, &mut roles)); + } + } + } + roles +} + /// A changed object that was a page in the signed revision, judged by what the page shows rather /// than by any `/Type` its new version carries. Only added annotations are permitted; any other /// difference (content, resources, a relabelled or dropped `/Type`) is a page content change. @@ -1576,6 +1704,27 @@ impl Default for AppearanceLabels { } } +impl AppearanceLabels { + /// Each label with its field name (`signed_by`, `distinguished_name`, `reason`, `location`, + /// `date`), in the order the visible signature shows them. + pub fn fields(&self) -> [(&'static str, &str); 5] { + [ + ("signed_by", self.signed_by.as_str()), + ("distinguished_name", self.distinguished_name.as_str()), + ("reason", self.reason.as_str()), + ("location", self.location.as_str()), + ("date", self.date.as_str()), + ] + } + + /// The first label, with its field name, that has a character the visible signature's font + /// can't draw, and that character. Signing refuses such a label when it is shown, so an + /// interface checks its translated labels here and falls back to [`Default`] if one fails. + pub fn first_undrawable(&self) -> Option<(&'static str, char)> { + self.fields().into_iter().find_map(|(field, text)| pdfcraft_fonts::first_undrawable(text).map(|c| (field, c))) + } +} + #[derive(Clone, Debug, Default)] pub struct SignOptions { /// Sign this existing, unsigned signature field. @@ -1714,9 +1863,12 @@ fn sign_inner( let (widget, rect) = match existing { Some(f) => { let f = f?; - if f.dict.contains(b"V") { + 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()))?; @@ -1916,6 +2068,7 @@ pub fn display_date(pdf: &str) -> String { /// 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. fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result { use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap}; let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0)); @@ -1936,6 +2089,17 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate ))); } } + // The labels are the caller's words (a translation), checked as the details are: only + // those that are drawn, as each is drawn only beside its detail. + let has = |v: Option<&str>| v.is_some_and(|v| !v.is_empty()); + let drawn = [a.name, a.distinguished_name, a.reason && has(opts.reason.as_deref()), a.location && has(opts.location.as_deref()), a.date]; + let mut labels = opts.appearance_labels.fields().into_iter().zip(drawn).filter(|&(_, on)| a.labels && on); + if let Some((field, c)) = labels.find_map(|((field, text), _)| first_undrawable(text).map(|c| (field, c))) { + return Err(SignError::Unsupported(format!( + "{}; pass that label in Western European characters, or leave the labels out of the visible signature", + undrawable_message(&format!("the {field} label of the visible signature"), c) + ))); + } } let mut lines: Vec = Vec::new(); let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() }; @@ -2038,3 +2202,760 @@ pub(crate) fn fmt(v: f64) -> String { let s = format!("{v:.2}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } + +// ── validated layered signature appearances (display only) ────────────────────────────────── + +/// A copy of `doc` in which every validated (`Status::Valid`), visible signature widget that +/// carries a layered unverified appearance (a frame Form XObject invoking a standalone unverified +/// caption layer, a separate signer-details text layer, and a vector question-mark/status glyph +/// layer) has its caption updated to `"Signature valid"` and its glyph replaced by a green +/// checkmark drawn behind the text layers. Returns `None` when no widget qualifies. +pub fn with_validated_appearances(doc: &Document, signatures: &[SignatureInfo]) -> Option { + if !signatures.iter().any(|s| s.signed && s.visible && s.status == Status::Valid) { + return None; + } + let mut copy: Option = None; + for f in sig_fields(doc) { + if !signatures.iter().any(|s| s.signed && s.visible && s.status == Status::Valid && s.field == f.name) { + continue; + } + for (widget_ref, wd) in &f.widgets { + let Some(widget_ref) = *widget_ref else { continue }; + let Some(patch) = plan_validated_widget(doc, widget_ref, wd) else { continue }; + apply_validated_widget(copy.get_or_insert_with(|| doc.clone()), patch); + } + } + copy +} + +struct ValidatedWidgetPatch { + widget_ref: ObjRef, + widget_dict: Dict, + ap_ref: Option, + ap_dict: Dict, + frame_dict: Dict, + frame_xobjects: Dict, + frame_ops: Vec, + glyph_ref: ObjRef, + glyph_dict: Dict, + glyph_ops: Vec, + caption_ref: ObjRef, + caption_dict: Dict, + caption_ops: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LayerRole { + Caption, + Words, + Glyph, + Other, +} + +#[derive(Clone)] +struct DoCall { + name: Vec, + matrix: Option<[f64; 6]>, +} + +struct FormScan { + do_calls: Vec, + has_text_ops: bool, + text_lines: Vec, + path_points: Vec<(f64, f64)>, + has_curves: bool, + has_re: bool, + has_inline_image: bool, + has_other_ops: bool, + has_unwrapped_do: bool, +} + +/// Plans a widget-local appearance rewrite for a supported layered valid signature. +fn plan_validated_widget(doc: &Document, widget_ref: ObjRef, wd: &Dict) -> Option { + let ap = doc.resolve(wd.get(b"AP")?).as_dict().cloned()?; + let ap_ref = wd.get(b"AP").and_then(Object::as_ref); + let ap_n = ap.reference(b"N")?; + let (_frame_ref, frame_stream, frame_bbox, xobjects, frame_scan) = find_frame_form(doc, ap_n)?; + let mut roles: Vec<(DoCall, ObjRef, LayerRole)> = Vec::with_capacity(frame_scan.do_calls.len()); + for call in &frame_scan.do_calls { + let layer_ref = xobjects.reference(&call.name)?; + let Object::Stream(layer_stream) = &*doc.get(layer_ref) else { return None }; + if layer_stream.dict.name(b"Subtype") != Some(b"Form") { + return None; + } + let layer_bytes = layer_stream.decoded().ok()?; + let lscan = scan_form_stream(&layer_bytes); + if lscan.has_inline_image { + return None; + } + // A layer that invokes an Image XObject is raster artwork, never a vector status glyph. + if !lscan.do_calls.is_empty() { + roles.push((call.clone(), layer_ref, LayerRole::Other)); + continue; + } + let role = if is_unverified_caption(&lscan.text_lines) { + LayerRole::Caption + } else if is_question_mark_text(&lscan.text_lines) { + LayerRole::Glyph + } else if lscan.has_text_ops { + LayerRole::Words + } else if !lscan.path_points.is_empty() { + let lbbox = form_bbox(doc, &layer_stream.dict)?; + if lscan.has_curves || !is_perimeter_border(&lscan.path_points, lbbox) { LayerRole::Glyph } else { LayerRole::Other } + } else { + LayerRole::Other + }; + roles.push((call.clone(), layer_ref, role)); + } + let mut caption_refs: Vec = roles.iter().filter(|(_, _, r)| *r == LayerRole::Caption).map(|(_, o, _)| *o).collect(); + caption_refs.sort_unstable_by_key(|r| (r.num, r.generation)); + caption_refs.dedup(); + let [caption_ref] = caption_refs.as_slice() else { return None }; + + let has_words = roles.iter().any(|(_, _, r)| *r == LayerRole::Words); + if !has_words { + return None; + } + + let mut glyph_refs: Vec = roles.iter().filter(|(_, _, r)| *r == LayerRole::Glyph).map(|(_, o, _)| *o).collect(); + glyph_refs.sort_unstable_by_key(|r| (r.num, r.generation)); + glyph_refs.dedup(); + let [glyph_ref] = glyph_refs.as_slice() else { return None }; + + if caption_ref == glyph_ref + || roles.iter().any(|(_, o, r)| (*o == *caption_ref && *r != LayerRole::Caption) || (*o == *glyph_ref && *r != LayerRole::Glyph)) + { + return None; + } + + let Object::Stream(glyph_stream) = &*doc.get(*glyph_ref) else { return None }; + let mut glyph_dict = glyph_stream.dict.clone(); + glyph_dict.remove(b"Filter"); + glyph_dict.remove(b"DecodeParms"); + glyph_dict.remove(b"Matrix"); + glyph_dict.set(b"BBox".to_vec(), Object::Array(frame_bbox.iter().map(|v| Object::Real(*v)).collect())); + let glyph_ops = valid_glyph_ops(frame_bbox); + + let Object::Stream(cap_stream) = &*doc.get(*caption_ref) else { return None }; + let cap_bbox = form_bbox(doc, &cap_stream.dict)?; + let mut caption_dict = cap_stream.dict.clone(); + caption_dict.remove(b"Filter"); + caption_dict.remove(b"DecodeParms"); + let mut cap_res = caption_dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut cap_fonts = cap_res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let mut helv = Dict::new(); + helv.set(b"Type".to_vec(), Object::name("Font")); + helv.set(b"Subtype".to_vec(), Object::name("Type1")); + helv.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + helv.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + cap_fonts.set(b"F1".to_vec(), Object::Dict(helv)); + cap_res.set(b"Font".to_vec(), Object::Dict(cap_fonts)); + caption_dict.set(b"Resources".to_vec(), Object::Dict(cap_res)); + let caption_ops = valid_caption_ops(cap_bbox, frame_bbox); + + // Draw the glyph layer before the text layers (Words and Caption) so the green tick sits + // behind the signer text and caption instead of obscuring them. + let mut ordered = roles; + if let (Some(first_text), Some(glyph_pos)) = ( + ordered.iter().position(|(_, _, r)| matches!(r, LayerRole::Words | LayerRole::Caption)), + ordered.iter().position(|(_, _, r)| *r == LayerRole::Glyph), + ) && glyph_pos > first_text + { + let item = ordered.remove(glyph_pos); + ordered.insert(first_text, item); + } + let mut frame_ops = String::new(); + for (call, _, role) in &ordered { + let name = String::from_utf8_lossy(&call.name); + match (role, call.matrix) { + (LayerRole::Glyph, _) | (_, None) => { + frame_ops.push_str(&format!("q /{name} Do Q\n")); + } + (_, Some([a, b, c, d, e, f])) => { + frame_ops.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", fmt(a), fmt(b), fmt(c), fmt(d), fmt(e), fmt(f))); + } + } + } + let mut frame_dict = frame_stream.dict.clone(); + frame_dict.remove(b"Filter"); + frame_dict.remove(b"DecodeParms"); + + Some(ValidatedWidgetPatch { + widget_ref, + widget_dict: wd.clone(), + ap_ref, + ap_dict: ap, + frame_dict, + frame_xobjects: xobjects, + frame_ops: frame_ops.into_bytes(), + glyph_ref: *glyph_ref, + glyph_dict, + glyph_ops, + caption_ref: *caption_ref, + caption_dict, + caption_ops, + }) +} + +/// Applies a validated appearance patch using cloned widget-local PDF objects. +fn apply_validated_widget(doc: &mut Document, patch: ValidatedWidgetPatch) { + let glyph_ref = doc.add(Object::Stream(Stream::from_raw(patch.glyph_dict, patch.glyph_ops))); + let caption_ref = doc.add(Object::Stream(Stream::from_raw(patch.caption_dict, patch.caption_ops))); + let frame_xobjects = patch + .frame_xobjects + .iter() + .map(|(name, object)| { + let object = match object.as_ref() { + Some(reference) if reference == patch.glyph_ref => Object::Ref(glyph_ref), + Some(reference) if reference == patch.caption_ref => Object::Ref(caption_ref), + _ => object.clone(), + }; + (name.clone(), object) + }) + .collect::(); + let mut frame_dict = patch.frame_dict; + let mut resources = frame_dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + resources.set(b"XObject".to_vec(), Object::Dict(frame_xobjects)); + let resources_ref = doc.add(Object::Dict(resources)); + frame_dict.set(b"Resources".to_vec(), Object::Ref(resources_ref)); + let frame_ref = doc.add(Object::Stream(Stream::from_raw(frame_dict, patch.frame_ops))); + let mut ap_dict = patch.ap_dict; + ap_dict.set(b"N".to_vec(), Object::Ref(frame_ref)); + let mut widget_dict = patch.widget_dict; + if patch.ap_ref.is_some() { + let ap_ref = doc.add(Object::Dict(ap_dict)); + widget_dict.set(b"AP".to_vec(), Object::Ref(ap_ref)); + } else { + widget_dict.set(b"AP".to_vec(), Object::Dict(ap_dict)); + } + doc.set(patch.widget_ref, Object::Dict(widget_dict)); +} + +/// Finds the safe layered frame form beneath an appearance wrapper. +fn find_frame_form(doc: &Document, start: ObjRef) -> Option<(ObjRef, Stream, [f64; 4], Dict, FormScan)> { + let mut curr = start; + let mut seen = HashSet::new(); + for _ in 0..4 { + if !seen.insert(curr) { + return None; + } + let Object::Stream(s) = &*doc.get(curr) else { return None }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return None; + } + let bytes = s.decoded().ok()?; + let scan = scan_form_stream(&bytes); + if scan.has_inline_image { + return None; + } + let res = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned())?; + let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + if scan.do_calls.len() == 1 && !scan.has_other_ops && !scan.has_unwrapped_do { + let call = scan.do_calls.first()?; + let wrapper_transformed = + nums(doc, &s.dict, b"Matrix").and_then(|m| <[f64; 6]>::try_from(m.as_slice()).ok()).is_some_and(|m| !is_identity_matrix(m)); + if wrapper_transformed || call.matrix.is_some() { + return None; + } + curr = xobjects.reference(&call.name)?; + continue; + } + if scan.do_calls.len() >= 2 && !scan.has_other_ops && !scan.has_unwrapped_do { + let bbox = form_bbox(doc, &s.dict)?; + return Some((curr, s.clone(), bbox, xobjects, scan)); + } + return None; + } + None +} + +/// Reads and normalizes a finite, non-empty Form XObject bounding box. +fn form_bbox(doc: &Document, d: &Dict) -> Option<[f64; 4]> { + let b = nums(doc, d, b"BBox")?; + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(b.as_slice()).ok()?; + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) { + return None; + } + let (min_x, max_x) = (x0.min(x1), x0.max(x1)); + let (min_y, max_y) = (y0.min(y1), y0.max(y1)); + (max_x - min_x > 0.5 && max_y - min_y > 0.5).then_some([min_x, min_y, max_x, max_y]) +} + +/// Reports whether extracted layer text is a known unverified-signature caption. +fn is_unverified_caption(lines: &[String]) -> bool { + if lines.is_empty() { + return false; + } + let joined: String = lines.iter().flat_map(|l| l.split_whitespace()).collect::>().join(" ").to_ascii_lowercase(); + matches!(joined.as_str(), "signature not verified" | "validity unknown" | "signature validity is unknown") +} + +/// Reports whether extracted layer text is the standalone question-mark glyph. +fn is_question_mark_text(lines: &[String]) -> bool { + let joined: String = lines.iter().flat_map(|l| l.split_whitespace()).collect::>().join(" "); + joined == "?" +} + +/// Reports whether path points form a border around most of a layer’s bounding box. +fn is_perimeter_border(points: &[(f64, f64)], bbox: [f64; 4]) -> bool { + if points.len() < 4 { + return false; + } + let [bx0, by0, bx1, by1] = bbox; + let (bw, bh) = (bx1 - bx0, by1 - by0); + if bw <= 0.0 || bh <= 0.0 { + return false; + } + let min_x = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let max_x = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); + let min_y = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let max_y = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + if max_x - min_x < 0.85 * bw || max_y - min_y < 0.85 * bh { + return false; + } + let margin = 0.12 * bw.min(bh); + points.iter().all(|&(x, y)| { + let dx = (x - bx0).abs().min((bx1 - x).abs()); + let dy = (y - by0).abs().min((by1 - y).abs()); + dx.min(dy) <= margin + }) +} + +/// Builds PDF operators for the green checkmark used in a validated display copy. +fn valid_glyph_ops(bbox: [f64; 4]) -> Vec { + let [bx0, by0, bx1, by1] = bbox; + let (bw, bh) = (bx1 - bx0, by1 - by0); + let s = bw.min(bh); + let ox = bx0 + (bw - s) * 0.5; + let oy = by0 + (bh - s) * 0.5; + let d = s * 0.03; + let lw = (s * 0.012).max(0.5); + const PTS: [(f64, f64); 6] = [(0.15, 0.42), (0.23, 0.53), (0.40, 0.34), (0.74, 0.86), (0.85, 0.78), (0.42, 0.16)]; + let mut out = String::from("q\n0 g\n"); + for (i, &(px, py)) in PTS.iter().enumerate() { + let x = ox + px * s + d; + let y = oy + py * s - d; + out.push_str(&format!("{} {} {}\n", fmt(x), fmt(y), if i == 0 { "m" } else { "l" })); + } + out.push_str(&format!("h f\n0 G\n0 0.68 0.15 rg\n{} w\n0 j\n", fmt(lw))); + for (i, &(px, py)) in PTS.iter().enumerate() { + let x = ox + px * s; + let y = oy + py * s; + out.push_str(&format!("{} {} {}\n", fmt(x), fmt(y), if i == 0 { "m" } else { "l" })); + } + out.push_str("h B\nQ\n"); + out.into_bytes() +} + +/// Builds PDF text operators for the validated signature caption. +fn valid_caption_ops(cap_bbox: [f64; 4], frame_bbox: [f64; 4]) -> Vec { + let [bx0, by0, bx1, by1] = cap_bbox; + let (bw, bh) = ((bx1 - bx0).max(1.0), (by1 - by0).max(1.0)); + let frame_bh = (frame_bbox[3] - frame_bbox[1]).abs(); + // When a caption layer shares the full widget BBox instead of a dedicated top strip, + // place the caption in the top 30% strip where standard layered captions sit. + let (strip_y, strip_h) = if frame_bh > 0.0 && (bh - frame_bh).abs() < 1.0 && bh > bw * 0.45 { (by0 + bh * 0.70, bh * 0.30) } else { (by0, bh) }; + let label = "Signature valid"; + let pad = (bw * 0.015).clamp(0.5, 4.0); + let unit_w = pdfcraft_fonts::helvetica_width(label, 1.0).max(1.0); + let size = ((bw - 2.0 * pad).max(1.0) / unit_w).min(strip_h * 0.85).max(1.0); + let x = bx0 + pad; + let y = strip_y + ((strip_h - size * 0.72).max(0.0)) * 0.5; + format!("BT\n0 g\n/F1 {} Tf\n{} {} Td\n({label}) Tj\nET\n", fmt(size), fmt(x), fmt(y)).into_bytes() +} + +#[derive(Clone)] +enum StreamToken { + Name(Vec), + Number(f64), + Str(Vec), + Array(Vec), + Op(Vec), +} + +fn is_pdf_ws(b: u8) -> bool { + matches!(b, 0x00 | 0x09 | 0x0A | 0x0C | 0x0D | 0x20) +} + +fn is_pdf_delim(b: u8) -> bool { + matches!(b, b'/' | b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'%') +} + +/// Parses the next token from a PDF content stream, including nested arrays. +fn next_stream_token(bytes: &[u8], pos: &mut usize, depth: usize) -> Option { + if depth > 6 { + return None; + } + while let Some(&b) = bytes.get(*pos) { + if is_pdf_ws(b) { + *pos += 1; + } else if b == b'%' { + *pos += 1; + while let Some(&c) = bytes.get(*pos) { + *pos += 1; + if c == b'\n' || c == b'\r' { + break; + } + } + } else { + break; + } + } + let &first = bytes.get(*pos)?; + match first { + b'/' => { + *pos += 1; + let mut name = Vec::new(); + while let Some(&b) = bytes.get(*pos) { + if is_pdf_ws(b) || is_pdf_delim(b) { + break; + } + if b == b'#' + && let (Some(&h1), Some(&h2)) = (bytes.get(*pos + 1), bytes.get(*pos + 2)) + && let (Some(d1), Some(d2)) = ((h1 as char).to_digit(16), (h2 as char).to_digit(16)) + { + name.push(((d1 << 4) | d2) as u8); + *pos += 3; + continue; + } + name.push(b); + *pos += 1; + } + Some(StreamToken::Name(name)) + } + b'(' => { + *pos += 1; + let mut out = Vec::new(); + let mut paren = 1usize; + while let Some(&b) = bytes.get(*pos) { + *pos += 1; + match b { + b'(' => { + paren += 1; + out.push(b'('); + } + b')' => { + paren -= 1; + if paren == 0 { + break; + } + out.push(b')'); + } + b'\\' => { + let Some(&esc) = bytes.get(*pos) else { break }; + *pos += 1; + match esc { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'(' => out.push(b'('), + b')' => out.push(b')'), + b'\\' => out.push(b'\\'), + b'\r' => { + if bytes.get(*pos) == Some(&b'\n') { + *pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut oct = u32::from(esc - b'0'); + for _ in 0..2 { + if let Some(&d @ b'0'..=b'7') = bytes.get(*pos) { + oct = (oct << 3) | u32::from(d - b'0'); + *pos += 1; + } else { + break; + } + } + out.push((oct & 0xFF) as u8); + } + other => out.push(other), + } + } + other => out.push(other), + } + } + Some(StreamToken::Str(out)) + } + b'<' => { + if bytes.get(*pos + 1) == Some(&b'<') { + *pos += 2; + Some(StreamToken::Op(b"<<".to_vec())) + } else { + *pos += 1; + let mut hex = Vec::new(); + while let Some(&b) = bytes.get(*pos) { + *pos += 1; + if b == b'>' { + break; + } + hex.push(b); + } + Some(StreamToken::Str(unhex(&hex).unwrap_or_default())) + } + } + b'>' => { + if bytes.get(*pos + 1) == Some(&b'>') { + *pos += 2; + Some(StreamToken::Op(b">>".to_vec())) + } else { + *pos += 1; + Some(StreamToken::Op(b">".to_vec())) + } + } + b'[' => { + *pos += 1; + let mut items = Vec::new(); + while let Some(&b) = bytes.get(*pos) { + if is_pdf_ws(b) { + *pos += 1; + continue; + } + if b == b']' { + *pos += 1; + break; + } + let before = *pos; + if let Some(tok) = next_stream_token(bytes, pos, depth + 1) { + items.push(tok); + } + if *pos == before { + *pos += 1; + } + } + Some(StreamToken::Array(items)) + } + b']' => { + *pos += 1; + Some(StreamToken::Op(b"]".to_vec())) + } + _ => { + let mut raw = Vec::new(); + while let Some(&b) = bytes.get(*pos) { + if is_pdf_ws(b) || is_pdf_delim(b) { + break; + } + raw.push(b); + *pos += 1; + } + if let Ok(s) = std::str::from_utf8(&raw) + && let Ok(n) = s.parse::() + && n.is_finite() + { + return Some(StreamToken::Number(n)); + } + Some(StreamToken::Op(raw)) + } + } +} + +/// Decodes PDFDocEncoding bytes into text used while classifying appearance layers. +fn decode_pdf_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|b| pdfcraft_cos::pdfdoc_char(*b)).collect() +} + +/// Multiplies two PDF affine transformation matrices in content-stream order. +fn concat_matrix(a: [f64; 6], b: [f64; 6]) -> [f64; 6] { + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +/// Reports whether a PDF affine matrix is effectively the identity transform. +fn is_identity_matrix(m: [f64; 6]) -> bool { + (m[0] - 1.0).abs() < 1e-4 && m[1].abs() < 1e-4 && m[2].abs() < 1e-4 && (m[3] - 1.0).abs() < 1e-4 && m[4].abs() < 1e-4 && m[5].abs() < 1e-4 +} + +/// Scans a Form XObject stream for text, paths, nested layers, and unsafe operators. +fn scan_form_stream(bytes: &[u8]) -> FormScan { + let mut scan = FormScan { + do_calls: Vec::new(), + has_text_ops: false, + text_lines: Vec::new(), + path_points: Vec::new(), + has_curves: false, + has_re: false, + has_inline_image: false, + has_other_ops: false, + has_unwrapped_do: false, + }; + let mut pos = 0usize; + let mut args: Vec = Vec::new(); + let mut ctm_stack: Vec<[f64; 6]> = vec![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]]; + let mut current_line = String::new(); + let mut last_tm_y: Option = None; + let flush_line = |line: &mut String, lines: &mut Vec| { + let trimmed = line.trim(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + } + line.clear(); + }; + while pos < bytes.len() { + let before = pos; + let Some(tok) = next_stream_token(bytes, &mut pos, 0) else { break }; + if pos == before { + pos += 1; + } + match tok { + StreamToken::Op(op) => { + if !matches!(op.as_slice(), b"q" | b"Q" | b"cm" | b"Do") { + scan.has_other_ops = true; + } + match op.as_slice() { + b"BI" => { + scan.has_inline_image = true; + break; + } + b"q" => { + if ctm_stack.len() < 32 + && let Some(&top) = ctm_stack.last() + { + ctm_stack.push(top); + } + } + b"Q" => { + if ctm_stack.len() > 1 { + ctm_stack.pop(); + } else if let Some(top) = ctm_stack.first_mut() { + *top = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + } + } + b"cm" => { + if let Some(nums) = last_n_numbers(&args, 6) + && let Ok(m) = <[f64; 6]>::try_from(nums.as_slice()) + && let Some(top) = ctm_stack.last_mut() + { + *top = concat_matrix(m, *top); + } + } + b"Do" => { + if ctm_stack.len() == 1 { + scan.has_unwrapped_do = true; + } + if let Some(StreamToken::Name(name)) = args.last() { + let ctm = ctm_stack.last().copied().unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + scan.do_calls.push(DoCall { name: name.clone(), matrix: (!is_identity_matrix(ctm)).then_some(ctm) }); + } + } + b"BT" => { + scan.has_text_ops = true; + last_tm_y = None; + } + b"ET" => { + scan.has_text_ops = true; + flush_line(&mut current_line, &mut scan.text_lines); + last_tm_y = None; + } + b"Td" | b"TD" => { + if let Some(nums) = last_n_numbers(&args, 2) + && let [_, ty] = nums.as_slice() + && ty.abs() > 0.01 + { + flush_line(&mut current_line, &mut scan.text_lines); + } + } + b"T*" => { + flush_line(&mut current_line, &mut scan.text_lines); + } + b"Tm" => { + if let Some(nums) = last_n_numbers(&args, 6) + && let [.., ty] = nums.as_slice() + { + if last_tm_y.is_some_and(|prev| (*ty - prev).abs() > 0.1) { + flush_line(&mut current_line, &mut scan.text_lines); + } + last_tm_y = Some(*ty); + } + } + b"Tj" => { + scan.has_text_ops = true; + if let Some(StreamToken::Str(s)) = args.last() { + current_line.push_str(&decode_pdf_bytes(s)); + } + } + b"'" | b"\"" => { + scan.has_text_ops = true; + flush_line(&mut current_line, &mut scan.text_lines); + if let Some(StreamToken::Str(s)) = args.last() { + current_line.push_str(&decode_pdf_bytes(s)); + } + } + b"TJ" => { + scan.has_text_ops = true; + if let Some(StreamToken::Array(items)) = args.last() { + for item in items { + match item { + StreamToken::Str(s) => current_line.push_str(&decode_pdf_bytes(s)), + StreamToken::Number(n) if *n <= -120.0 && !current_line.ends_with(' ') => current_line.push(' '), + _ => {} + } + } + } + } + b"m" | b"l" => { + if let Some(nums) = last_n_numbers(&args, 2) + && let [x, y] = nums.as_slice() + { + scan.path_points.push((*x, *y)); + } + } + b"v" | b"y" => { + scan.has_curves = true; + if let Some(nums) = last_n_numbers(&args, 4) + && let [x1, y1, x2, y2] = nums.as_slice() + { + scan.path_points.push((*x1, *y1)); + scan.path_points.push((*x2, *y2)); + } + } + b"c" => { + scan.has_curves = true; + if let Some(nums) = last_n_numbers(&args, 6) + && let [x1, y1, x2, y2, x3, y3] = nums.as_slice() + { + scan.path_points.push((*x1, *y1)); + scan.path_points.push((*x2, *y2)); + scan.path_points.push((*x3, *y3)); + } + } + b"re" if last_n_numbers(&args, 4).is_some() => { + scan.has_re = true; + } + _ => {} + } + args.clear(); + } + other => { + if args.len() >= 16 { + args.remove(0); + } + args.push(other); + } + } + } + flush_line(&mut current_line, &mut scan.text_lines); + scan +} + +fn last_n_numbers(args: &[StreamToken], n: usize) -> Option> { + if args.len() < n { + return None; + } + let slice = args.get(args.len() - n..)?; + let mut out = Vec::with_capacity(n); + for tok in slice { + match tok { + StreamToken::Number(v) => out.push(*v), + _ => return None, + } + } + Some(out) +} diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index 856d75e3..66bf7c8a 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -173,9 +173,7 @@ fn reconstructed_and_unreadable_objects_are_not_cached_as_complete() { } // A single invalid keyword immediately before endobj is deliberately recovered as // Null by parse_indirect. Use a stream without endstream to exercise a real read error. - // Its text names /ByteRange: the scan parses only objects that do (a timestamp needs one), - // so an unreadable object that could be a timestamp must keep discovery incomplete. - let unreadable = fixture(&[STAMP, "<< /Length 0 /ByteRange [0 1 2 1] >>\nstream\nunterminated"]); + let unreadable = fixture(&[STAMP, "<< /Length 0 >>\nstream\nunterminated"]); assert!(unreadable.repair_log().is_empty()); assert!(unreadable.try_get(4).is_err()); assert!(unreadable.scan_objects_checked().any(|(r, object)| r.num == 4 && object.is_err())); diff --git a/crates/sign/src/pkcs12.rs b/crates/sign/src/pkcs12.rs index ab83e747..a71d5c85 100644 --- a/crates/sign/src/pkcs12.rs +++ b/crates/sign/src/pkcs12.rs @@ -6,7 +6,8 @@ //! Files that aren't clean DER still open: trailing whitespace, PEM armour or a bare //! base64 body (#159). //! Writing uses PBES2 AES-256-CBC with PBKDF2-HMAC-SHA-256 and an HMAC-SHA-256 MAC, OpenSSL 3's -//! defaults. +//! algorithms, at [`WRITE_ROUNDS`] rather than OpenSSL's 2048. Opening stops at a fixed amount +//! of key-derivation work, whatever the file asks for. //! //! The envelope is read as BER, not DER: Windows writes `.p12` files with indefinite lengths and //! the AuthenticatedSafe split into OCTET STRING segments, and nothing in the envelope is signed @@ -48,6 +49,42 @@ const PBE_SHA_2DES: &str = "1.2.840.113549.1.12.1.4"; const PBE_SHA_RC2_128: &str = "1.2.840.113549.1.12.1.5"; const PBE_SHA_RC2_40: &str = "1.2.840.113549.1.12.1.6"; +/// Key-derivation rounds for new files: OWASP's figure for PBKDF2-HMAC-SHA-256. The MAC uses the +/// same count, because checking it alone tells whether a guessed password is right (#1847). +pub const WRITE_ROUNDS: u32 = 600_000; + +/// The shortest password a new digital ID may have (NIST SP 800-63B). Opening an existing file +/// takes any password, so nobody is locked out of an ID made before this minimum. +pub const MIN_PASSWORD_CHARS: usize = 8; + +/// The most key-derivation work one [`open`] does, in hash blocks summed over every derivation +/// in the file. The file names its own iteration counts, so without a budget a crafted file +/// hangs the caller on the password check (#1847). A file written by [`write`] needs about +/// 2 million; real files from other tools need far less. +const OPEN_BUDGET: u64 = 10_000_000; + +/// What is left of [`OPEN_BUDGET`] while one file is opened. +struct Budget(u64); + +impl Budget { + /// Spend `rounds` iterations to derive `len` bytes with `alg`, and return them as the `u32` + /// the derivation takes, or refuse the file if the budget runs out. + fn spend(&mut self, alg: DigestAlg, rounds: u64, len: usize) -> Result { + // Every derivation hashes at least once, and repeats the rounds for each output block. + let blocks = len.div_ceil(alg.digest(&[]).len().max(1)).max(1) as u64; + let cost = rounds.max(1).saturating_mul(blocks); + match (self.0.checked_sub(cost), u32::try_from(rounds)) { + (Some(left), Ok(rounds)) => { + self.0 = left; + Ok(rounds) + } + _ => { + Err(SignError::Unsupported(format!("PKCS #12 files that need more than {OPEN_BUDGET} rounds of key derivation ({rounds} asked for)"))) + } + } + } +} + /// What a digital ID file holds: the signing key, its certificate and any others (issuers). #[derive(Debug)] pub struct DigitalId { @@ -182,7 +219,7 @@ fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) } /// Decrypt `ct` with the password-based scheme `alg` (an AlgorithmIdentifier). -fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result, SignError> { +fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result, SignError> { let parts = alg.children()?; let o = parts.first().ok_or_else(|| bad("algorithm"))?.oid()?; let params = parts.get(1).ok_or_else(|| bad("algorithm parameters"))?; @@ -196,7 +233,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result, Sign } let kp = kdf.get(1).ok_or_else(|| bad("PBKDF2 parameters"))?.children()?; let salt = kp.first().ok_or_else(|| bad("salt"))?.octets("salt")?; - let rounds = kp.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32; + let rounds = kp.get(1).ok_or_else(|| bad("iterations"))?.u64()?; let prf = match kp .iter() .skip(2) @@ -222,13 +259,14 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result, Sign other => return Err(SignError::Unsupported(format!("PBES2 cipher {}", other.unwrap_or("?")))), }; let iv = sc.get(1).ok_or_else(|| bad("IV"))?.octets("IV")?; + let rounds = budget.spend(prf, rounds, cipher.key_len())?; let key = pbkdf2(prf, password.as_bytes(), &salt, rounds, cipher.key_len())?; cipher.decrypt(&key, &iv, ct) } PBE_SHA_3DES | PBE_SHA_2DES | PBE_SHA_RC2_128 | PBE_SHA_RC2_40 => { let p = params.children()?; let salt = p.first().ok_or_else(|| bad("salt"))?.octets("salt")?; - let rounds = p.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32; + let rounds = p.get(1).ok_or_else(|| bad("iterations"))?.u64()?; let (cipher, key_len, iv_len) = match o.as_str() { PBE_SHA_3DES => (Cipher::TripleDes, 24, 8), PBE_SHA_2DES => (Cipher::TripleDes, 16, 8), @@ -236,6 +274,9 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result, Sign _ => (Cipher::Rc2(40), 5, 8), }; let pw = bmp(password); + // Two derivations from the same count: the key, then the IV. + let rounds = budget.spend(DigestAlg::Sha1, rounds, key_len)?; + budget.spend(DigestAlg::Sha1, u64::from(rounds), iv_len)?; let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len); if key.len() == 16 && matches!(cipher, Cipher::TripleDes) { // Two-key 3DES: K1 K2 K1. @@ -260,7 +301,7 @@ enum BagKind { Cert(Vec), } -fn bags(safe_contents: &[u8], password: &str, out: &mut Vec) -> Result<(), SignError> { +fn bags(safe_contents: &[u8], password: &str, out: &mut Vec, budget: &mut Budget) -> Result<(), SignError> { for bag in Tlv::parse_all_ber(safe_contents)?.children()? { let f = bag.children()?; let Some(id) = f.first().and_then(|o| o.oid().ok()) else { continue }; @@ -282,7 +323,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec) -> Result<(), SHROUDED_KEY_BAG => { let p = value.children()?; let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) }; - BagKind::Key(pbe_decrypt(alg, password, &data.octets("encrypted private key")?)?) + BagKind::Key(pbe_decrypt(alg, password, &data.octets("encrypted private key")?, budget)?) } CERT_BAG => { let p = value.children()?; @@ -355,6 +396,7 @@ pub fn open(bytes: &[u8], password: &str) -> Result { return Err(SignError::Unsupported("public-key protected PKCS #12 files".into())); } let content = auth_safe.get(1).ok_or_else(|| bad("authSafe content"))?.inner()?.octets("authSafe")?; + let mut budget = Budget(OPEN_BUDGET); // The MAC proves the password before anything is decrypted. It covers the contents of that // OCTET STRING — the segments joined, if the file is segmented, which is what wrote them. if let Some(mac) = pfx.get(2) { @@ -367,8 +409,9 @@ pub fn open(bytes: &[u8], password: &str) -> Result { .ok_or_else(|| SignError::Unsupported(format!("MAC digest {alg_oid}")))?; let expected = digest_info.get(1).ok_or_else(|| bad("MAC"))?.octets("MAC")?; let salt = m.get(1).ok_or_else(|| bad("MAC salt"))?.octets("MAC salt")?; - let iterations = m.get(2).map(|i| i.u64()).transpose()?.unwrap_or(1) as u32; + let iterations = m.get(2).map(|i| i.u64()).transpose()?.unwrap_or(1); let key_len = alg.digest(&[]).len(); + let iterations = budget.spend(alg, iterations, key_len)?; let key = pkcs12_kdf(alg, &bmp(password), &salt, 3, iterations, key_len); if hmac(alg, &key, &content)? != *expected { return Err(SignError::WrongPassword); @@ -380,7 +423,7 @@ pub fn open(bytes: &[u8], password: &str) -> Result { let kind = p.first().ok_or_else(|| bad("ContentInfo"))?.oid()?; let body = p.get(1).ok_or_else(|| bad("ContentInfo content"))?.inner()?; match kind.as_str() { - DATA => bags(&body.octets("SafeContents")?, password, &mut all)?, + DATA => bags(&body.octets("SafeContents")?, password, &mut all, &mut budget)?, ENCRYPTED_DATA => { let ed = body.children()?; let eci = ed.get(1).ok_or_else(|| bad("EncryptedContentInfo"))?.children()?; @@ -397,8 +440,8 @@ pub fn open(bytes: &[u8], password: &str) -> Result { } _ => return Err(bad("encryptedContent")), }; - let plain = pbe_decrypt(alg, password, &ct)?; - bags(&plain, password, &mut all)?; + let plain = pbe_decrypt(alg, password, &ct, &mut budget)?; + bags(&plain, password, &mut all, &mut budget)?; } _ => {} } @@ -441,16 +484,15 @@ fn random(n: usize) -> Result, SignError> { /// Write a digital ID as a `.p12` protected by `password`. pub fn write(id: &DigitalId, password: &str) -> Result, SignError> { - const ITER: u32 = 2048; let encrypt = |plain: &[u8]| -> Result, SignError> { let (salt, iv) = (random(16)?, random(16)?); - let key = pbkdf2(DigestAlg::Sha256, password.as_bytes(), &salt, ITER, 32)?; + let key = pbkdf2(DigestAlg::Sha256, password.as_bytes(), &salt, WRITE_ROUNDS, 32)?; let ct = cbc::Encryptor::::new_from_slices(&key, &iv) .map_err(|_| SignError::Crypto("AES".into()))? .encrypt_padded_vec::(plain); let kdf = der::seq(&[ &der::oid(PBKDF2), - &der::seq(&[&der::octets(&salt), &der::int(ITER as u64), &der::seq(&[&der::oid(HMAC_SHA256), &der::null()])]), + &der::seq(&[&der::octets(&salt), &der::int(WRITE_ROUNDS as u64), &der::seq(&[&der::oid(HMAC_SHA256), &der::null()])]), ]); let alg = der::seq(&[&der::oid(PBES2), &der::seq(&[&kdf, &der::seq(&[&der::oid(AES256_CBC), &der::octets(&iv)])])]); Ok(der::seq(&[&alg, &der::octets(&ct)])) @@ -480,8 +522,31 @@ pub fn write(id: &DigitalId, password: &str) -> Result, SignError> { let ci_key = der::seq(&[&der::oid(DATA), &der::explicit(0, &der::octets(&der::seq(&[&key_bag])))]); let auth_safe = der::seq(&[&ci_certs, &ci_key]); let salt = random(16)?; - let mac_key = pkcs12_kdf(DigestAlg::Sha256, &bmp(password), &salt, 3, ITER, 32); + let mac_key = pkcs12_kdf(DigestAlg::Sha256, &bmp(password), &salt, 3, WRITE_ROUNDS, 32); let mac = hmac(DigestAlg::Sha256, &mac_key, &auth_safe)?; - let mac_data = der::seq(&[&der::seq(&[&DigestAlg::Sha256.algorithm(), &der::octets(&mac)]), &der::octets(&salt), &der::int(ITER as u64)]); + let mac_data = der::seq(&[&der::seq(&[&DigestAlg::Sha256.algorithm(), &der::octets(&mac)]), &der::octets(&salt), &der::int(WRITE_ROUNDS as u64)]); Ok(der::seq(&[&der::int(3), &der::seq(&[&der::oid(DATA), &der::explicit(0, &der::octets(&auth_safe))]), &mac_data])) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_budget_covers_every_derivation_in_a_file() { + let mut budget = Budget(OPEN_BUDGET); + // What a file from `write` needs: the MAC and two PBKDF2 derivations. + for _ in 0..3 { + assert_eq!(budget.spend(DigestAlg::Sha256, WRITE_ROUNDS.into(), 32).ok(), Some(WRITE_ROUNDS)); + } + // Six million rounds fit once, but not twice. + assert!(budget.spend(DigestAlg::Sha256, 6_000_000, 32).is_ok()); + assert!(budget.spend(DigestAlg::Sha256, 6_000_000, 32).is_err()); + // Each output block repeats the rounds: 24 bytes of SHA-1 are two blocks. + let mut budget = Budget(OPEN_BUDGET); + assert!(budget.spend(DigestAlg::Sha1, 6_000_000, 24).is_err()); + // Zero rounds still hashes once. + assert_eq!(budget.spend(DigestAlg::Sha1, 0, 20).ok(), Some(0)); + assert_eq!(budget.0, OPEN_BUDGET - 1); + } +} diff --git a/crates/sign/src/windows.rs b/crates/sign/src/windows.rs index 3974aff8..275bdb52 100644 --- a/crates/sign/src/windows.rs +++ b/crates/sign/src/windows.rs @@ -2,7 +2,7 @@ //! Private keys remain in CNG; Windows may ask permission to use a key. //! //! Certificates that can't sign through PdfCraft (no usable private key, a key type it doesn't -//! sign with) are reported in [`Listing::unusable`] with the reason rather than left out +//! sign with, a key usage or extended key usage that doesn't allow signing documents) are reported in [`Listing::unusable`] with the reason rather than left out //! silently, so a missing identity can be explained (issue #179). use std::sync::Arc; @@ -161,6 +161,13 @@ fn entries() -> Result { continue; } } + // A smart card holds encryption and logon certificates beside the signing one, all under + // the same name; a signature made with them doesn't validate, so they aren't offered. + // Checked after the key, so a certificate without one stays out of the list entirely. + if let Some(reason) = certificate.signing_problem_clause() { + skip(certificate.display_name(), reason, false); + continue; + } let public = certificate.public_key.clone(); let key = PrivateKey::external(public.clone(), Arc::new(WindowsKey { context: context.clone(), public })); let friendly_name = Some(certificate.display_name()); diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index e3e784ba..6f4a731e 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -231,11 +231,15 @@ impl Extensions { self.authority_key_id = aki.children().ok().and_then(|c| c.into_iter().find(|t| t.tag == tag::ctx(0))).map(|t| t.value.to_vec()); } } - // ExtendedKeyUsage: a SEQUENCE OF OID. + // ExtendedKeyUsage: a SEQUENCE OF OID. Like keyUsage, one that is present restricts + // the key even when it doesn't parse: an unreadable one allows no purpose at all. "2.5.29.37" => { - if let Ok(eku) = Tlv::parse_all(value.value) { - self.extended_key_usage = eku.children().ok().map(|c| c.into_iter().filter_map(|t| t.oid().ok()).collect::>()); - } + let purposes = Tlv::parse_all(value.value) + .ok() + .filter(|eku| eku.tag == tag::SEQUENCE) + .and_then(|eku| eku.children().ok()) + .and_then(|c| c.iter().map(|t| t.oid().ok()).collect::>>()); + self.extended_key_usage = Some(purposes.unwrap_or_default()); } // Authority Information Access: OCSP and CA-issuer locations. "1.3.6.1.5.5.7.1.1" => { @@ -380,6 +384,15 @@ impl Certificate { } } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's + /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. + pub fn signing_problem_clause(&self) -> Option { + self.signing_problem().map(|d| match d.message.strip_prefix("The signer's certificate ") { + Some(rest) => format!("it {}", rest.strip_suffix('.').unwrap_or(rest)), + None => d.message, + }) + } + /// Issued by itself (subject = issuer and its own key verifies it). pub fn is_self_signed(&self) -> bool { self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key) diff --git a/crates/sign/tests/chain.rs b/crates/sign/tests/chain.rs index e8fd0127..59834104 100644 --- a/crates/sign/tests/chain.rs +++ b/crates/sign/tests/chain.rs @@ -281,6 +281,21 @@ fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() { assert_eq!(status, Status::Valid, "{details:?}"); } +#[test] +fn a_smart_cards_certificates_that_may_not_sign_say_why_in_id_lists() { + // A DoD CAC's four certificates, all under the same name; only the signing one may sign. + let r = root(); + let clause = |exts: &[Vec]| signer_with("DOE.JANE.1234567890", &r, exts).cert.signing_problem_clause(); + let encryption = clause(&[key_usage(KEY_ENCIPHERMENT), extended_key_usage(&["1.3.6.1.5.5.7.3.4"])]); + assert_eq!(encryption.as_deref(), Some("it does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)")); + let logon = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["1.3.6.1.4.1.311.20.2.2", "1.3.6.1.5.5.7.3.2"])]); + assert_eq!(logon.as_deref(), Some("it is not issued for signing documents (its extended key usage has no document-signing purpose)")); + let card_auth = clause(&[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.16.840.1.101.3.6.8"])]); + assert_eq!(card_auth, logon); + let signing = clause(&[key_usage(DIGITAL_SIGNATURE | 0x40), extended_key_usage(&["1.3.6.1.5.5.7.3.4", "1.3.6.1.4.1.311.10.3.12"])]); + assert_eq!(signing, None); +} + #[test] fn unreadable_constraint_extensions_fail_closed() { // keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign. @@ -298,3 +313,30 @@ fn unreadable_constraint_extensions_fail_closed() { let c = issue("Garbled Constraints", None, FOREVER, &[garbage]); assert!(c.cert.has_basic_constraints && !c.cert.is_ca && !c.cert.may_issue()); } + +#[test] +fn an_unreadable_extended_key_usage_allows_no_purpose() { + // An extendedKeyUsage that is present restricts the key even when it doesn't parse (#1807): + // truncated, critical or not; a SET instead of a SEQUENCE; or a member that isn't an OID. + // Each would otherwise read as "no restriction", or as only its readable members. + let r = root(); + let any = der::oid("2.5.29.37.0"); + let truncated = [0x30, 0x05, 0x06]; + let malformed = [ + ext("2.5.29.37", &truncated), + der::seq(&[&der::oid("2.5.29.37"), &der::octets(&truncated)]), + ext("2.5.29.37", &der::tlv(tag::SET, &any)), + ext("2.5.29.37", &der::seq(&[&der::int(1), &any])), + ]; + for (i, eku) in malformed.into_iter().enumerate() { + let signer = signer_with("Malformed EKU", &r, &[key_usage(DIGITAL_SIGNATURE), eku]); + assert_eq!(signer.cert.extended_key_usage.as_deref(), Some(&[][..]), "case {i}"); + let (status, details) = verdict(signer, vec![], &r.cert); + assert_eq!(status, Status::Unknown, "case {i}: {details:?}"); + assert!(details.iter().any(|d| d.contains("not issued for signing documents")), "case {i}: {details:?}"); + } + // The same document-signing purpose, well formed, may sign. + let (status, details) = + verdict(signer_with("Good EKU", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&["2.5.29.37.0"])]), vec![], &r.cert); + assert_eq!(status, Status::Valid, "{details:?}"); +} diff --git a/crates/sign/tests/crypto.rs b/crates/sign/tests/crypto.rs index a7e6931c..ef3d5976 100644 --- a/crates/sign/tests/crypto.rs +++ b/crates/sign/tests/crypto.rs @@ -185,6 +185,48 @@ fn new_digital_ids_are_self_signed_and_survive_a_p12_round_trip() { } } +#[test] +fn new_digital_ids_use_the_stronger_round_count_for_the_key_and_the_mac() { + let name = Name::build("Grace Hopper", "", "", "", ""); + let now = Time { year: 2026, month: 10, day: 14, hour: 12, minute: 0, second: 0 }; + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let certificate = Certificate::self_signed(&name, &key, now, 5, &[0x42]).unwrap(); + let id = pkcs12::DigitalId { key, certificate, chain: Vec::new(), friendly_name: None }; + let p12 = pkcs12::write(&id, "s3cret12").unwrap(); + // OWASP's figure for PBKDF2-HMAC-SHA-256, spelled out so lowering the constant fails here. + const ROUNDS: u64 = 600_000; + // MacData's iteration count, the last field of the file. + let pfx = Tlv::parse_all(&p12).unwrap().children().unwrap(); + let mac = pfx[2].children().unwrap(); + assert_eq!(mac[2].u64().unwrap(), ROUNDS); + // The same INTEGER three times: the MAC and the PBKDF2 parameters of both encrypted parts. + let rounds = pdfcraft_sign::der::int(ROUNDS); + assert_eq!(p12.windows(rounds.len()).filter(|w| *w == rounds.as_slice()).count(), 3); + assert_eq!(pkcs12::open(&p12, "s3cret12").unwrap().certificate, id.certificate); +} + +/// A `.p12` whose MAC asks for `rounds` iterations. Its content is empty: opening it must stop +/// at the MAC, before the content is read. +fn p12_with_mac_rounds(rounds: u64) -> Vec { + use pdfcraft_sign::der; + let auth_safe = der::seq(&[]); + let digest = der::seq(&[&DigestAlg::Sha256.algorithm(), &der::octets(&[0; 32])]); + let mac_data = der::seq(&[&digest, &der::octets(&[1; 16]), &der::int(rounds)]); + der::seq(&[&der::int(3), &der::seq(&[&der::oid("1.2.840.113549.1.7.1"), &der::explicit(0, &der::octets(&auth_safe))]), &mac_data]) +} + +#[test] +fn a_p12_asking_for_too_many_rounds_is_refused_before_hashing() { + // 2^32 + 1 used to wrap to one round; 20 million took seconds and 4 billion about an hour, + // all spent on the password check (#1847). + for rounds in [(1u64 << 32) + 1, 20_000_000, 4_000_000_000] { + let err = pkcs12::open(&p12_with_mac_rounds(rounds), "password").expect_err("refused"); + assert!(matches!(err, SignError::Unsupported(_)), "{rounds}: {err}"); + } + // An ordinary count still gets as far as the password. + assert!(matches!(pkcs12::open(&p12_with_mac_rounds(2048), "password"), Err(SignError::WrongPassword))); +} + #[test] fn certificates_load_from_pem_and_der_and_export_as_pem() { let pem_text = data("rsa.crt.pem"); diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 54d76af6..908a5c0f 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -132,6 +132,60 @@ fn signing_an_existing_field_and_counter_signing() { )); } +/// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: +/// it is listed as unsigned and can be signed by name. +#[test] +fn a_field_with_a_null_value_is_unsigned_and_can_be_signed() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let field = pdfcraft_cos::ObjRef { num: 6, generation: 0 }; + let direct = edit_after(&fixture(), |doc| doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Null)).unwrap()); + let indirect = edit_after(&fixture(), |doc| { + let null = doc.add(Object::Null); + doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(null))).unwrap(); + }); + for (what, unsigned) in [("direct null", direct), ("indirect null", indirect)] { + let doc = open(&unsigned); + assert!(doc.get(field).as_dict().is_some_and(|d| d.contains(b"V")), "{what}: the fixture keeps its /V"); + let listed = signatures(&doc, &unsigned, &TrustStore::default()); + assert!(listed.iter().any(|s| s.field == "Approval" && !s.signed), "{what}"); + let signed = + pdfcraft_sign::sign(&doc, &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap_or_else(|e| panic!("{what}: {e:?}")); + let listed = signatures(&open(&signed), &signed, &TrustStore::default()); + assert!(listed.iter().any(|s| s.field == "Approval" && s.signed), "{what}"); + assert!(!listed.iter().any(|s| s.field == "Signature1"), "{what}: no new field"); + } +} + +/// 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 { let mut doc = open(signed); @@ -888,7 +942,7 @@ fn a_dss_entry_does_not_make_the_object_it_names_part_of_the_store() { #[test] fn an_existing_dictionary_is_not_a_store_part_because_the_store_names_it() { // The font dictionary (object 5), rewritten and then listed as the store, its /VRI, a VRI - // entry, or simply labelled /Type /DSS. + // entry, or simply labelled /Type /DSS. The page draws it, so each is page content (#1787). let rewrite_font = |doc: &mut Document, extra: Option<(&str, Object)>| { let mut d = dict_of(vec![("Type", Object::name("Font")), ("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Courier"))]); if let Some((k, v)) = extra { @@ -896,19 +950,19 @@ fn an_existing_dictionary_is_not_a_store_part_because_the_store_names_it() { } doc.set(pdfcraft_cos::ObjRef { num: 5, generation: 0 }, Object::Dict(d)); }; - assert_refused_at_every_level(&fixture(), "other changes", |doc| { + assert_refused_at_every_level(&fixture(), "page content", |doc| { rewrite_font(doc, Some(("Type", Object::name("DSS")))); }); - assert_refused_at_every_level(&fixture(), "other changes", |doc| { + assert_refused_at_every_level(&fixture(), "page content", |doc| { set_dss(doc, oref(5)); rewrite_font(doc, None); }); - assert_refused_at_every_level(&fixture(), "other changes", |doc| { + assert_refused_at_every_level(&fixture(), "page content", |doc| { set_dss(doc, Object::Dict(dict_of(vec![("VRI", oref(5))]))); rewrite_font(doc, None); }); // An existing dictionary with only DSS-looking keys that the signed file never had as a store. - assert_refused_at_every_level(&fixture(), "other changes", |doc| { + assert_refused_at_every_level(&fixture(), "page content", |doc| { doc.set( pdfcraft_cos::ObjRef { num: 5, generation: 0 }, Object::Dict(dict_of(vec![("Type", Object::name("Font")), ("Cert", Object::Array(vec![]))])), @@ -931,7 +985,8 @@ fn an_existing_array_stays_what_it_is_when_the_store_points_at_it() { #[test] fn a_dictionary_labelled_metadata_is_not_a_metadata_change() { // Only the catalog's XMP stream is metadata (a stream, not a dictionary a label was put on). - assert_refused_at_every_level(&fixture(), "other changes", |doc| { + // The dictionary is the page's font, so the rewrite is page content (#1787). + assert_refused_at_every_level(&fixture(), "page content", |doc| { doc.set( pdfcraft_cos::ObjRef { num: 5, generation: 0 }, Object::Dict(dict_of(vec![ @@ -988,9 +1043,10 @@ fn fixture_with_stream_7(catalog_extra: &str, drawn: bool, dict: &str, body: &[u #[test] fn a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it() { // A Form XObject the page draws, labelled /Type /Metadata before signing (the renderer ignores - // /Type). Rewriting it later changes what the page shows, so it is not a metadata change. + // /Type). Rewriting it later changes what the page shows, so it is not a metadata change + // but page content (#1787). let base = fixture_with_stream_7("", true, "/Type /Metadata /Subtype /Form /BBox [0 0 300 300]", b"0 0 1 rg 10 10 100 100 re f"); - assert_refused_at_every_level(&base, "other changes", |doc| { + assert_refused_at_every_level(&base, "page content", |doc| { let d = dict_of(vec![ ("Type", Object::name("Metadata")), ("Subtype", Object::name("Form")), @@ -1139,7 +1195,8 @@ fn a_form_xobject_relabelled_as_file_plumbing_is_still_judged() { }); let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap(); assert_eq!(s.status, Status::Invalid, "{certify:?} /{label}: {:?}", s.details); - assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification); + // The page draws the XObject: its rewrite is page content (#1787). + assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")), "{:?}", s.modification); } } } @@ -1584,10 +1641,15 @@ fn validates_the_legacy_adbe_x509_rsa_sha1_format() { /// `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 { + protected_with(alg, "", permissions) +} + +/// `fixture()` protected with the user password `user` and an owner password, then saved. +fn protected_with(alg: pdfcraft_cos::Algorithm, user: &str, permissions: i32) -> Vec { let mut doc = open(&fixture()); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm: alg, - user_password: "", + user_password: user, owner_password: "owner", permissions, encrypt_metadata: true, @@ -1597,6 +1659,38 @@ fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec { pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() } +/// #1808: a document that asks for its user password, signed and then counter-signed. The +/// first signature no longer covers the whole file, so its revision is reopened to classify the +/// change; that revision is encrypted too, and used to be unreadable without the password, +/// which made the first signature Invalid. It opens with the document's own key. +#[test] +fn a_user_password_document_keeps_its_earlier_signature_valid_when_counter_signed() { + 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_with(alg, "secret", -1); + assert!(Document::open_with_password(Arc::new(base.clone()), None).is_err(), "{alg:?}: the file asks for its password"); + let signed = pdfcraft_sign::sign(&open_pw(&base, Some("secret")), &id, &opts()).unwrap(); + let both = pdfcraft_sign::sign(&open_pw(&signed, Some("secret")), &id2, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + let sigs = signatures(&open_pw(&both, Some("secret")), &both, &trust); + let first = sigs.iter().find(|s| s.field == "Signature1").unwrap(); + assert_eq!(first.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{alg:?}: {:?}", first.details); + assert_eq!(first.status, Status::Valid, "{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); + // Opened as owner, the same. + let sigs = signatures(&open_pw(&both, Some("owner")), &both, &trust); + assert!(sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Valid), "{alg:?}"); + // Tampering inside the first signature's range still breaks both. + let mut tampered = both.clone(); + let at = base.len() / 2; + tampered[at] ^= 1; + let tampered_sigs = signatures(&open_pw(&tampered, Some("secret")), &tampered, &trust); + assert!(tampered_sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Invalid), "{alg:?}"); + } +} + fn open_pw(b: &[u8], pw: Option<&str>) -> Document { Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap() } @@ -1761,6 +1855,58 @@ fn visible_appearance_text_helvetica_cannot_draw_is_refused() { assert_eq!(s.reason.as_deref(), Some("同意此文件")); } +/// The labels are the caller's words (a translation), so they are checked as the details are: a +/// label Helvetica can't draw is refused with the label and the character, never drawn as `?`. +/// Only labels that are drawn count: one whose detail is hidden, or empty, or with the labels +/// turned off or the signature invisible, signs. Labels it can draw are what the stamp says. +#[test] +fn appearance_labels_helvetica_cannot_draw_are_refused() { + use pdfcraft_sign::{Appearance, AppearanceLabels}; + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + assert_eq!(AppearanceLabels::default().first_undrawable(), None, "the English labels are drawable"); + let chinese = AppearanceLabels { signed_by: "数字签名者".into(), reason: "原因:".into(), ..AppearanceLabels::default() }; + assert_eq!(chinese.first_undrawable(), Some(("signed_by", '数'))); + let sign = |labels: AppearanceLabels, appearance: Appearance, o: SignOptions| { + pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { appearance_labels: labels, appearance, ..o }) + }; + + // The name is shown with its label (the default appearance): refused, saying which and what. + let err = sign(chinese.clone(), Appearance::default(), opts()).unwrap_err(); + let m = err.to_string(); + assert!(matches!(err, SignError::Unsupported(_)), "{err:?}"); + assert!(m.contains("\"数\" (U+6570)") && m.contains("the signed_by label") && m.contains("leave the labels out"), "{m}"); + let russian_date = AppearanceLabels { date: "Дата: ".into(), ..AppearanceLabels::default() }; + let m = sign(russian_date.clone(), Appearance::default(), opts()).unwrap_err().to_string(); + assert!(m.contains("the date label") && m.contains("U+0414"), "{m}"); + let reason_only = AppearanceLabels { reason: "原因:".into(), ..AppearanceLabels::default() }; + let shown = Appearance { reason: true, ..Appearance::default() }; + assert!(sign(reason_only.clone(), shown.clone(), opts()).unwrap_err().to_string().contains("the reason label")); + + // Not drawn: the reason hidden, or empty; the labels off; the signature invisible. + sign(reason_only.clone(), Appearance::default(), opts()).unwrap(); + sign(reason_only.clone(), shown.clone(), SignOptions { reason: None, ..opts() }).unwrap(); + sign(reason_only, shown, SignOptions { reason: Some(String::new()), ..opts() }).unwrap(); + sign(chinese.clone(), Appearance { labels: false, ..Appearance::default() }, opts()).unwrap(); + sign(russian_date, Appearance { date: false, ..Appearance::default() }, opts()).unwrap(); + sign(chinese, Appearance::default(), SignOptions { rect: None, ..opts() }).unwrap(); + + // Labels Helvetica can draw are stamped as given, in place of the English ones. + let german = AppearanceLabels { + signed_by: "Digital signiert von".into(), + distinguished_name: "DN: ".into(), + reason: "Grund: ".into(), + location: "Ort: ".into(), + date: "Datum: ".into(), + }; + let all = Appearance { reason: true, location: true, ..Appearance::default() }; + let signed = sign(german, all, opts()).unwrap(); + let (content, _) = appearance_of(&signed, "Signature1"); + for word in ["(Digital", "(Grund:", "(Ort:", "(Datum:"] { + assert!(content.contains(word), "{word}: {content}"); + } + assert!(!content.contains("Reason") && !content.contains("Date:") && !content.contains('?'), "{content}"); +} + /// 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, @@ -1846,3 +1992,210 @@ fn details_say_themselves() { assert_eq!(holes, wanted, "{:?} has {holes} holes", d.pattern); } } + +/// `signed` with one more revision appended by hand: a new definition of object 4 (the page's +/// content stream) that draws `text`, then a cross-reference table section whose subsections are +/// what `xref` returns for the offset of that definition. The section continues the signed chain. +fn append_content_revision(signed: &[u8], text: &str, xref: impl Fn(usize) -> String) -> Vec { + let at = signed.windows(9).rposition(|w| w == b"startxref").unwrap() + 9; + let prev: String = signed[at..].iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect(); + let mut out = signed.to_vec(); + let content = format!("BT /F1 14 Tf 20 250 Td ({text}) Tj ET"); + let object = out.len(); + out.extend_from_slice(format!("4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content.len()).as_bytes()); + let section = out.len(); + out.extend_from_slice( + format!("xref\n{}trailer\n<< /Size 60 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n", xref(object)).as_bytes(), + ); + out +} + +fn xref_entry(offset: usize) -> String { + format!("{offset:010} 00000 n \n") +} + +/// The reader that judges changes and the renderer that draws the page resolve some +/// cross-reference entries differently. A revision appended after signing can redefine the page's +/// content and give the object an entry the reader sets aside (an in-use entry at offset 0, or +/// a second entry after one that still names the signed object): the reader then keeps comparing +/// the signed object with itself while the page shows the new one. Such an entry is never a +/// permitted change. +#[test] +fn a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + for certify in LEVELS { + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap(); + // Where the signed revision keeps the page's content stream. + let signed_at = signed.windows(8).position(|w| w == b"\n4 0 obj").unwrap() + 1; + // Each writes the section's subsections, given where the new object 4 was put. + type Subsections = Box String>; + let attacks: [(&str, Subsections); 3] = [ + ("an in-use entry at offset 0", Box::new(|_| format!("4 1\n{}", xref_entry(0)))), + ("a second entry for the object", Box::new(move |new| format!("4 1\n{}4 1\n{}", xref_entry(signed_at), xref_entry(new)))), + // The plain case, which was always caught: one entry naming the new object. + ("an ordinary entry", Box::new(|new| format!("4 1\n{}", xref_entry(new)))), + ]; + for (name, xref) in attacks { + let edited = append_content_revision(&signed, "Forged text", xref); + let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Invalid, "{certify:?}, {name}: {:?}", s.details); + assert!( + matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")), + "{certify:?}, {name}: {:?}", + s.modification + ); + } + } +} + +/// An entry that does not point at the object it names leaves the reader to find the object by +/// searching the file. What it finds may equal the signed object, yet another reader may search +/// differently: the object cannot be called unchanged. +#[test] +fn a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object() { + 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(); + // A copy of the signed content, named by an entry two bytes into its `4 0 obj` header. + let edited = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new + 2))); + let doc = open(&edited); + assert!(!doc.xref_entry_is_exact(4) && doc.xref_entry_is_exact(3)); + let s = signatures(&doc, &edited, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Invalid, "{:?}", s.details); + assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification); + + // The same copy under an entry that does point at it is the unchanged object it looks like. + let honest = append_content_revision(&signed, "Contract text", |new| format!("4 1\n{}", xref_entry(new))); + let s = signatures(&open(&honest), &honest, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); +} + +/// `fixture()` with the page's resources indirect: object 7 is the resources dictionary, 8 its +/// `/Font` dictionary, 9 an `/ExtGState` the content uses, and 5 the font as before. +fn fixture_with_indirect_resources() -> Vec { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + 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 7 0 R /Annots [6 0 R] >>", + b"<< /Length 52 >>\nstream\n/GS1 gs BT /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 >>", + b"<< /Font 8 0 R /ExtGState << /GS1 9 0 R >> >>", + b"<< /F1 5 0 R >>", + b"<< /Type /ExtGState /ca 1 >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A signed page's resources are judged by the role they held when signed, not by the keys a +/// later revision writes into them (#1787): rewriting the page's resources dictionary, a +/// category dictionary, a font or a graphics state changes what the page shows, however the +/// new version is labelled (/FT, /DR, /T + /Kids, /Subtype /Widget, /Type /Annot + /Rect, which +/// otherwise read as form filling or a comment, permitted under P=2 and P=3). A category +/// dictionary that only gained a name changes nothing drawn and is judged as before. +#[test] +fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() { + /// Replace object `num` with a dictionary; each attack runs once per certification level, + /// so the entries are cloned into each. + fn set(num: u32, entries: Vec<(&'static str, Object)>) -> impl Fn(&mut Document) { + move |doc| doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, Object::Dict(dict_of(entries.clone()))) + } + /// A second font, so a name can be made to resolve elsewhere. + fn courier(doc: &mut Document) -> pdfcraft_cos::ObjRef { + doc.add(Object::Dict(dict_of(vec![ + ("Type", Object::name("Font")), + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Courier")), + ]))) + } + // The resources dictionary: another font under the same name, plus /FT. + assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| { + let f = courier(doc); + set( + 7, + vec![ + ("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))]))), + ("ExtGState", Object::Dict(dict_of(vec![("GS1", oref(9))]))), + ("FT", Object::name("Tx")), + ], + )(doc); + }); + // The /Font dictionary: the name now resolves elsewhere, plus /DR. + assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| { + let f = courier(doc); + set(8, vec![("F1", Object::Ref(f)), ("DR", Object::Dict(dict_of(vec![])))])(doc); + }); + // The graphics state: everything drawn through it becomes invisible, plus /T and /Kids. + assert_refused_at_every_level( + &fixture_with_indirect_resources(), + "page content", + set( + 9, + vec![ + ("Type", Object::name("ExtGState")), + ("ca", Object::Real(0.0)), + ("T", Object::String(PdfString::literal("x"))), + ("Kids", Object::Array(vec![])), + ], + ), + ); + // The font itself, relabelled a widget. + assert_refused_at_every_level( + &fixture_with_indirect_resources(), + "page content", + set(5, vec![("Type", Object::name("Font")), ("BaseFont", Object::name("Courier")), ("Subtype", Object::name("Widget"))]), + ); + // The resources dictionary relabelled a comment (permitted under P=3 and by an approval signature). + assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| { + let f = courier(doc); + set( + 7, + vec![ + ("Type", Object::name("Annot")), + ("Subtype", Object::name("Square")), + ("Rect", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)])), + ("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))]))), + ], + )(doc); + }); + // The same page resources inline in the page keep being page content through the page itself. + assert_refused_at_every_level(&fixture(), "page content", |doc| { + let f = courier(doc); + doc.update_dict(pdfcraft_cos::ObjRef { num: 3, generation: 0 }, |page| { + page.set(b"Resources".to_vec(), Object::Dict(dict_of(vec![("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))])))]))); + }) + .unwrap(); + }); + + // Positive control: a /Font dictionary that only gained a name changes nothing the signed + // content draws, so it is not page content (it is still an unclassified change, refused as + // "other", as before). + 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_with_indirect_resources()), &id, &SignOptions { certify: Some(3), ..opts() }).unwrap(); + let grown = edit_after(&signed, |doc| { + let f = courier(doc); + set(8, vec![("F1", oref(5)), ("F2", Object::Ref(f))])(doc); + }); + let s = signatures(&open(&grown), &grown, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!( + matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&"page content") && k.contains(&"other changes")), + "{:?}", + s.modification + ); +}