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