diff --git a/openpdf-core-legacy/src/main/java/com/lowagie/text/pdf/PdfNumber.java b/openpdf-core-legacy/src/main/java/com/lowagie/text/pdf/PdfNumber.java
index 8a785484e..0d52a0d2f 100644
--- a/openpdf-core-legacy/src/main/java/com/lowagie/text/pdf/PdfNumber.java
+++ b/openpdf-core-legacy/src/main/java/com/lowagie/text/pdf/PdfNumber.java
@@ -137,11 +137,20 @@ public PdfNumber(float value) {
/**
* Returns the primitive int value of this object.
+ *
+ * Some PDF producers write 32-bit values that are meant to be interpreted as a two's-complement
+ * signed integer (for example the {@code /P} entry of an encryption dictionary) using their
+ * equivalent unsigned decimal representation instead of the negative signed value, e.g.
+ * {@code 4294965956} instead of {@code -1340}. A plain {@code (int) value} narrowing cast on the
+ * underlying double would clamp such an out-of-range value to
+ * {@link Integer#MAX_VALUE} rather than wrapping it, silently corrupting values like that. Going
+ * through long first reproduces the C-style truncation to the low-order 32 bits that
+ * PDF producers/consumers expect.
*
* @return The value as int
*/
public int intValue() {
- return (int) value;
+ return (int) (long) value;
}
/**
diff --git a/openpdf-core-legacy/src/test/java/com/lowagie/text/pdf/PdfNumberTest.java b/openpdf-core-legacy/src/test/java/com/lowagie/text/pdf/PdfNumberTest.java
new file mode 100644
index 000000000..ae145c62f
--- /dev/null
+++ b/openpdf-core-legacy/src/test/java/com/lowagie/text/pdf/PdfNumberTest.java
@@ -0,0 +1,31 @@
+package com.lowagie.text.pdf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class PdfNumberTest {
+
+ /**
+ * Some PDF producers write the encryption dictionary's {@code /P} (permissions) entry using its
+ * equivalent unsigned 32-bit decimal representation instead of the negative signed value, e.g.
+ * {@code 4294965956} instead of {@code -1340}. {@link PdfNumber#intValue()} must truncate such a
+ * value to its low-order 32 bits rather than clamping it to {@link Integer#MAX_VALUE}, otherwise
+ * the permission bits used to derive the decryption key no longer match the value the producer
+ * used, and a document with an empty user password is wrongly reported as requiring a password.
+ */
+ @Test
+ void intValueTruncatesOutOfRangeUnsignedRepresentationInsteadOfClamping() {
+ assertEquals(-1340, new PdfNumber("4294965956").intValue());
+ assertEquals(-1, new PdfNumber("4294967295").intValue());
+ assertEquals(Integer.MIN_VALUE, new PdfNumber(String.valueOf(1L << 31)).intValue());
+ }
+
+ @Test
+ void intValueRoundTripsValuesWithinIntRange() {
+ assertEquals(-1340, new PdfNumber("-1340").intValue());
+ assertEquals(0, new PdfNumber("0").intValue());
+ assertEquals(Integer.MAX_VALUE, new PdfNumber(String.valueOf(Integer.MAX_VALUE)).intValue());
+ assertEquals(Integer.MIN_VALUE, new PdfNumber(String.valueOf(Integer.MIN_VALUE)).intValue());
+ }
+}
diff --git a/openpdf-core-legacy/src/test/java/com/lowagie/text/pdf/encryption/DecryptUnsignedPermissionsTest.java b/openpdf-core-legacy/src/test/java/com/lowagie/text/pdf/encryption/DecryptUnsignedPermissionsTest.java
new file mode 100644
index 000000000..909b970e1
--- /dev/null
+++ b/openpdf-core-legacy/src/test/java/com/lowagie/text/pdf/encryption/DecryptUnsignedPermissionsTest.java
@@ -0,0 +1,39 @@
+package com.lowagie.text.pdf.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.lowagie.text.pdf.PdfReader;
+import java.io.IOException;
+import java.io.InputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for a document that has no user password but is nevertheless reported by
+ * {@link PdfReader} as requiring one, because its {@code /Encrypt} dictionary writes the {@code /P}
+ * (permissions) entry using the unsigned 32-bit decimal representation of a negative value (e.g.
+ * {@code 4294965956} instead of {@code -1340}) - a convention some PDF producers use.
+ *
+ * {@link com.lowagie.text.pdf.PdfNumber#intValue()} used a plain {@code (int)} narrowing cast on the
+ * underlying double, which clamps such an out-of-range value to
+ * {@link Integer#MAX_VALUE} instead of truncating it to its low-order 32 bits. The corrupted
+ * permission value then fed into the standard security handler's key derivation, producing a user key
+ * that did not match {@code /U}, so {@link PdfReader} threw a {@link com.lowagie.text.exceptions.BadPasswordException}
+ * even though the correct (empty) password was supplied.
+ */
+class DecryptUnsignedPermissionsTest {
+
+ @Test
+ void opensWithEmptyPasswordDespiteUnsignedPermissionsValue() throws IOException {
+ try (InputStream resource = getClass()
+ .getResourceAsStream("/permissions/empty-user-password-unsigned-p-value.pdf")) {
+ PdfReader pdfReader = new PdfReader(resource);
+ assertTrue(pdfReader.isEncrypted(), "PdfReader fails to report test file to be encrypted.");
+ assertFalse(pdfReader.isOwnerPasswordUsed(), "PdfReader fails to report limited permissions.");
+ assertEquals(1, pdfReader.getNumberOfPages(),
+ "PdfReader fails to report the correct number of pages");
+ pdfReader.close();
+ }
+ }
+}
diff --git a/openpdf-core-legacy/src/test/resources/permissions/empty-user-password-unsigned-p-value.pdf b/openpdf-core-legacy/src/test/resources/permissions/empty-user-password-unsigned-p-value.pdf
new file mode 100644
index 000000000..d528d8b2c
Binary files /dev/null and b/openpdf-core-legacy/src/test/resources/permissions/empty-user-password-unsigned-p-value.pdf differ
diff --git a/openpdf-core-modern/src/main/java/org/openpdf/text/pdf/PdfNumber.java b/openpdf-core-modern/src/main/java/org/openpdf/text/pdf/PdfNumber.java
index b87784f2a..03b446af6 100644
--- a/openpdf-core-modern/src/main/java/org/openpdf/text/pdf/PdfNumber.java
+++ b/openpdf-core-modern/src/main/java/org/openpdf/text/pdf/PdfNumber.java
@@ -135,11 +135,20 @@ public PdfNumber(float value) {
/**
* Returns the primitive int value of this object.
+ *
+ * Some PDF producers write 32-bit values that are meant to be interpreted as a two's-complement
+ * signed integer (for example the {@code /P} entry of an encryption dictionary) using their
+ * equivalent unsigned decimal representation instead of the negative signed value, e.g.
+ * {@code 4294965956} instead of {@code -1340}. A plain {@code (int) value} narrowing cast on the
+ * underlying double would clamp such an out-of-range value to
+ * {@link Integer#MAX_VALUE} rather than wrapping it, silently corrupting values like that. Going
+ * through long first reproduces the C-style truncation to the low-order 32 bits that
+ * PDF producers/consumers expect.
*
* @return The value as int
*/
public int intValue() {
- return (int) value;
+ return (int) (long) value;
}
/**
diff --git a/openpdf-core-modern/src/test/java/org/openpdf/text/pdf/PdfNumberTest.java b/openpdf-core-modern/src/test/java/org/openpdf/text/pdf/PdfNumberTest.java
new file mode 100644
index 000000000..edc9e9f38
--- /dev/null
+++ b/openpdf-core-modern/src/test/java/org/openpdf/text/pdf/PdfNumberTest.java
@@ -0,0 +1,31 @@
+package org.openpdf.text.pdf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class PdfNumberTest {
+
+ /**
+ * Some PDF producers write the encryption dictionary's {@code /P} (permissions) entry using its
+ * equivalent unsigned 32-bit decimal representation instead of the negative signed value, e.g.
+ * {@code 4294965956} instead of {@code -1340}. {@link PdfNumber#intValue()} must truncate such a
+ * value to its low-order 32 bits rather than clamping it to {@link Integer#MAX_VALUE}, otherwise
+ * the permission bits used to derive the decryption key no longer match the value the producer
+ * used, and a document with an empty user password is wrongly reported as requiring a password.
+ */
+ @Test
+ void intValueTruncatesOutOfRangeUnsignedRepresentationInsteadOfClamping() {
+ assertEquals(-1340, new PdfNumber("4294965956").intValue());
+ assertEquals(-1, new PdfNumber("4294967295").intValue());
+ assertEquals(Integer.MIN_VALUE, new PdfNumber(String.valueOf(1L << 31)).intValue());
+ }
+
+ @Test
+ void intValueRoundTripsValuesWithinIntRange() {
+ assertEquals(-1340, new PdfNumber("-1340").intValue());
+ assertEquals(0, new PdfNumber("0").intValue());
+ assertEquals(Integer.MAX_VALUE, new PdfNumber(String.valueOf(Integer.MAX_VALUE)).intValue());
+ assertEquals(Integer.MIN_VALUE, new PdfNumber(String.valueOf(Integer.MIN_VALUE)).intValue());
+ }
+}
diff --git a/openpdf-core-modern/src/test/java/org/openpdf/text/pdf/encryption/DecryptUnsignedPermissionsTest.java b/openpdf-core-modern/src/test/java/org/openpdf/text/pdf/encryption/DecryptUnsignedPermissionsTest.java
new file mode 100644
index 000000000..a7fa3186b
--- /dev/null
+++ b/openpdf-core-modern/src/test/java/org/openpdf/text/pdf/encryption/DecryptUnsignedPermissionsTest.java
@@ -0,0 +1,39 @@
+package org.openpdf.text.pdf.encryption;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.openpdf.text.pdf.PdfReader;
+import java.io.IOException;
+import java.io.InputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for a document that has no user password but is nevertheless reported by
+ * {@link PdfReader} as requiring one, because its {@code /Encrypt} dictionary writes the {@code /P}
+ * (permissions) entry using the unsigned 32-bit decimal representation of a negative value (e.g.
+ * {@code 4294965956} instead of {@code -1340}) - a convention some PDF producers use.
+ *
+ * {@link org.openpdf.text.pdf.PdfNumber#intValue()} used a plain {@code (int)} narrowing cast on the
+ * underlying double, which clamps such an out-of-range value to
+ * {@link Integer#MAX_VALUE} instead of truncating it to its low-order 32 bits. The corrupted
+ * permission value then fed into the standard security handler's key derivation, producing a user key
+ * that did not match {@code /U}, so {@link PdfReader} threw a {@link org.openpdf.text.exceptions.BadPasswordException}
+ * even though the correct (empty) password was supplied.
+ */
+class DecryptUnsignedPermissionsTest {
+
+ @Test
+ void opensWithEmptyPasswordDespiteUnsignedPermissionsValue() throws IOException {
+ try (InputStream resource = getClass()
+ .getResourceAsStream("/permissions/empty-user-password-unsigned-p-value.pdf")) {
+ PdfReader pdfReader = new PdfReader(resource);
+ assertTrue(pdfReader.isEncrypted(), "PdfReader fails to report test file to be encrypted.");
+ assertFalse(pdfReader.isOwnerPasswordUsed(), "PdfReader fails to report limited permissions.");
+ assertEquals(1, pdfReader.getNumberOfPages(),
+ "PdfReader fails to report the correct number of pages");
+ pdfReader.close();
+ }
+ }
+}
diff --git a/openpdf-core-modern/src/test/resources/permissions/empty-user-password-unsigned-p-value.pdf b/openpdf-core-modern/src/test/resources/permissions/empty-user-password-unsigned-p-value.pdf
new file mode 100644
index 000000000..d528d8b2c
Binary files /dev/null and b/openpdf-core-modern/src/test/resources/permissions/empty-user-password-unsigned-p-value.pdf differ