diff --git a/vcl/inc/pdf/pdfwriter_impl.hxx b/vcl/inc/pdf/pdfwriter_impl.hxx index 65366b43360e..c0b31a636aef 100644 --- a/vcl/inc/pdf/pdfwriter_impl.hxx +++ b/vcl/inc/pdf/pdfwriter_impl.hxx @@ -1094,7 +1094,14 @@ private: bool emitTrailer(); /// whether this file may carry object streams and a cross-reference stream bool useObjectStreams() const; - /// writes a whole object, into an object stream where the file may have one + /// what encrypts a string as an object is built, and nothing for an object bound for a stream + std::shared_ptr getStringEncryptor(sal_Int32 nObject); +#ifndef NDEBUG + /// objects whose strings went in plain, checked once the last object stream is flushed + std::unordered_set m_aPlainStringObjects; +#endif + /// writes a whole object, into an object stream where the file may have one; a body + /// holding strings takes its encryptor from getStringEncryptor bool writeObject(sal_Int32 nObject, std::string_view aBody); /// writes an object's body into the pending object stream rather than into the file bool writeCompressedObject(sal_Int32 nObject, std::string_view aBody); diff --git a/vcl/qa/cppunit/pdfexport/PDFEncryptionTest.cxx b/vcl/qa/cppunit/pdfexport/PDFEncryptionTest.cxx index b59cfe80feb0..bc10ec757fdf 100644 --- a/vcl/qa/cppunit/pdfexport/PDFEncryptionTest.cxx +++ b/vcl/qa/cppunit/pdfexport/PDFEncryptionTest.cxx @@ -12,16 +12,21 @@ #include #include +#include #include #include #include +#include #include #include #include #include +#include +#include +#include #include #include @@ -88,6 +93,79 @@ CPPUNIT_TEST_FIXTURE(PDFEncryptionTest, testEncryptionRoundtrip_PDF_1_7) CPPUNIT_ASSERT_EQUAL(17, nFileVersion); } +// encryption used to cost a tagged file its object streams, and so most of its size +CPPUNIT_TEST_FIXTURE(PDFEncryptionTest, testEncryptedTaggedUsesObjectStreams) +{ + loadFromURL(u"private:factory/swriter"_ustr); + auto xText = mxComponent.queryThrow()->getText(); + xText->setString(u"Some tagged text"_ustr); + // a footnote, because its element is the one this writer gives an /ID to + auto xFactory = mxComponent.queryThrow(); + auto xFootnote = xFactory->createInstance(u"com.sun.star.text.Footnote"_ustr) + .queryThrow(); + xText->insertTextContent(xText->getEnd(), xFootnote, false); + xFootnote.queryThrow()->setString(u"The note this refers to"_ustr); + + uno::Sequence aFilterData{ comphelper::makePropertyValue(u"UseTaggedPDF"_ustr, true), + comphelper::makePropertyValue(u"EncryptFile"_ustr, true), + comphelper::makePropertyValue(u"DocumentOpenPassword"_ustr, + u"secret"_ustr) }; + mxComponent.queryThrow()->storeToURL( + maTempFile.GetURL(), + { comphelper::makePropertyValue(u"FilterName"_ustr, u"writer_pdf_Export"_ustr), + comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) }); + + std::unique_ptr pPdfDocument + = parsePDFExport(vcl::pdf::PDFiumLibrary::get(), "secret"_ostr); + + // the structure tree went into object streams, which is what the encryption used to cost. + // Only strings and stream data are encrypted, so the stream's own dictionary reads plainly + const std::string_view aFile(static_cast(maMemory.GetData()), maMemory.GetSize()); + // an unencrypted export has object streams anyway, so the rest tests nothing without this + CPPUNIT_ASSERT(aFile.find("/Encrypt") != std::string_view::npos); + const size_t nObjStm = aFile.find("/ObjStm"); + CPPUNIT_ASSERT(nObjStm != std::string_view::npos); + + // /Length has to count what the cipher adds, or a reader that trusts it stops mid-block. + // pdfium does not: it scans for endstream, so only the byte count catches this + // the writer puts exactly one newline here, and the cipher's own first byte may be another + const size_t nData = aFile.find(">>\nstream\n", nObjStm); + CPPUNIT_ASSERT(nData != std::string_view::npos); + const size_t nLength = aFile.find("/Length ", nObjStm); + CPPUNIT_ASSERT(nLength != std::string_view::npos); + // inside this dictionary, so it is this stream's length and not a later object's + CPPUNIT_ASSERT_LESS(nData, nLength); + const sal_Int32 nDeclared = o3tl::toInt32(aFile.substr(nLength + 8, 12)); + CPPUNIT_ASSERT(nData + 10 + nDeclared + 10 <= aFile.size()); + CPPUNIT_ASSERT_EQUAL("\nendstream"_ostr, OString(aFile.substr(nData + 10 + nDeclared, 10))); + + // reading the footnote element's /ID back through the password distinguishes a plain + // string from one encrypted a second time + CPPUNIT_ASSERT_EQUAL(1, pPdfDocument->getPageCount()); + auto pPage = pPdfDocument->openPage(0); + CPPUNIT_ASSERT(pPage); + auto pTree = pPage->getStructureTree(); + CPPUNIT_ASSERT(pTree); + CPPUNIT_ASSERT_GREATER(0, pTree->getNumberOfChildren()); + + OUString aID; + const auto aFindID = [&aID](auto& rSelf, vcl::pdf::PDFiumStructureElement& rElement) -> void { + if (aID.isEmpty()) + aID = rElement.getID(); + for (int i = 0; i < rElement.getNumberOfChildren() && aID.isEmpty(); ++i) + { + if (const auto pKid = rElement.getChild(i)) + rSelf(rSelf, *pKid); + } + }; + for (int i = 0; i < pTree->getNumberOfChildren() && aID.isEmpty(); ++i) + { + if (const auto pChild = pTree->getChild(i)) + aFindID(aFindID, *pChild); + } + CPPUNIT_ASSERT(aID.startsWith("id")); +} + CPPUNIT_TEST_FIXTURE(PDFEncryptionTest, testEncryptionRoundtrip_PDF_2_0) { loadFromURL(u"private:factory/swriter"_ustr); diff --git a/vcl/source/pdf/pdfwriter_impl.cxx b/vcl/source/pdf/pdfwriter_impl.cxx index a235af9b7cd8..3459e15e2d12 100644 --- a/vcl/source/pdf/pdfwriter_impl.cxx +++ b/vcl/source/pdf/pdfwriter_impl.cxx @@ -844,15 +844,26 @@ bool PDFWriterImpl::useObjectStreams() const { if (m_aContext.Version < PDFWriter::PDFVersion::PDF_1_5) return false; - // a string in an object stream shall not be separately encrypted, while this writer - // encrypts every string as it builds the object - if (m_aContext.Encryption.canEncrypt()) - return false; // sdext reads the embedded original document out of a trailer, and a cross-reference // stream leaves none return m_aDocumentAttachedFiles.empty(); } +std::shared_ptr PDFWriterImpl::getStringEncryptor(sal_Int32 nObject) +{ + if (!useObjectStreams()) + return m_pPDFEncryptor; + +#ifndef NDEBUG + m_aPlainStringObjects.insert(nObject); +#else + (void)nObject; +#endif + // ISO 32000-2 7.5.7: a string in an object stream is not encrypted on its own, the + // stream carrying it is + return nullptr; +} + bool PDFWriterImpl::writeObject(sal_Int32 nObject, std::string_view aBody) { if (useObjectStreams()) @@ -895,14 +906,24 @@ bool PDFWriterImpl::emitObjectStream() aLine.append(OString::number(nStreamObject) + " 0 obj\n<(m_aCompressedObjects.size())) + "/First " + OString::number(nFirst) + "/Length " - + OString::number(static_cast(aStream.size()))); + + OString::number(calculateStreamSize(aStream.size()))); if (!g_bDebugDisableCompression) aLine.append("/Filter/FlateDecode"); aLine.append(">>\nstream\n"); - if (!updateObject(nStreamObject) || !writeBuffer(aLine) - || !writeBufferBytes(aStream.data(), aStream.size()) - || !writeBuffer("\nendstream\nendobj\n\n")) + if (!updateObject(nStreamObject) || !writeBuffer(aLine)) + return false; + + // ISO 32000-2 7.5.7: a string in here is not encrypted on its own, the stream holding + // it is, with the cipher set up for the stream's own object number + checkAndEnableStreamEncryption(nStreamObject); + // the strings inside are covered by nothing else, so stream encryption must really be on + assert((!m_aContext.Encryption.canEncrypt() + || (m_pPDFEncryptor && m_pPDFEncryptor->isStreamEncryptionEnabled())) + && "an object stream is going out unencrypted"); + const bool bBodies = writeBufferBytes(aStream.data(), aStream.size()); + disableStreamEncryption(); + if (!bBodies || !writeBuffer("\nendstream\nendobj\n\n")) return false; // only now can an entry name the stream, which is in the file @@ -958,7 +979,7 @@ sal_Int32 PDFWriterImpl::emitStructIDTree(sal_Int32 const nObject) ids.emplace(GenerateID(n), n); } OStringBuffer buf; - COSWriter aWriter(buf, m_aContext.Encryption.getParams(), m_pPDFEncryptor); + COSWriter aWriter(buf, m_aContext.Encryption.getParams(), getStringEncryptor(nObject)); buf.append("<