tdf#157028 vcl: use object streams in an encrypted file too

Object streams and a cross-reference stream shrink a tagged PDF, but
I1e27dc3a8f43aa8b6c75ae930615c8d4b4645627 ("tdf#157028 vcl: write the
structure tree into object streams") left an encrypted export out of
both, so a password kept the whole structure tree uncompressed. The
spreadsheet reported in tdf#166233, attachment 171182, exported tagged
at 2368642 bytes, and at 13465120 with a password. The protected export
is 2389620 bytes now, and what it has over the plain tagged one is what
encryption itself costs.

This writer encrypts a string as it builds the object. ISO 32000-2 7.5.7
forbids encrypting a string in an object stream separately: the stream
that carries it is encrypted instead. So the strings of a stream-bound
object go in plain now, and the object stream is encrypted as a whole,
with the cipher set up for the stream's own object number rather than
for the objects inside. The stream's /Length counts what the cipher
adds. Only the structure elements and the ID tree put strings in a
stream, so only those two builders ask getStringEncryptor for one, and
it returns nothing while the file uses object streams.

Signed-off-by: Mike Kaganski <mike.kaganski@collabora.com>
Change-Id: Ia6a063244baf456d7c7368a6bba7a8cbb96e31b0
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/13370
Reviewed-by: Miklos Vajna <vmiklos@collabora.com>
Tested-by: Jenkins CPCI <releng@collaboraoffice.com>
(cherry picked from commit 59193295323917bd065ea3a235f7653ad7f9b04b)
Reviewed-on: https://gerrit.libreoffice.org/c/core/+/211655
Tested-by: Jenkins
Reviewed-by: Xisco Fauli <xiscofauli@libreoffice.org>
This commit is contained in:
Mike Kaganski
2026-10-01 15:43:44 +02:00
committed by Xisco Fauli
parent c80b6acef5
commit eec52071ef
3 changed files with 122 additions and 11 deletions
+8 -1
View File
@@ -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<pdf::IPDFEncryptor> getStringEncryptor(sal_Int32 nObject);
#ifndef NDEBUG
/// objects whose strings went in plain, checked once the last object stream is flushed
std::unordered_set<sal_Int32> 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);
@@ -12,16 +12,21 @@
#include <test/unoapi_test.hxx>
#include <o3tl/numeric.hxx>
#include <o3tl/string_view.hxx>
#include <comphelper/crypto/Crypto.hxx>
#include <comphelper/hash.hxx>
#include <comphelper/propertysequence.hxx>
#include <comphelper/propertyvalue.hxx>
#include <comphelper/sequenceashashmap.hxx>
#include <vcl/filter/PDFiumLibrary.hxx>
#include <com/sun/star/beans/XPropertySet.hpp>
#include <com/sun/star/frame/XStorable.hpp>
#include <com/sun/star/lang/XMultiServiceFactory.hpp>
#include <com/sun/star/text/XText.hpp>
#include <com/sun/star/text/XTextContent.hpp>
#include <com/sun/star/text/XTextCursor.hpp>
#include <com/sun/star/text/XTextDocument.hpp>
@@ -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<text::XTextDocument>()->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<lang::XMultiServiceFactory>();
auto xFootnote = xFactory->createInstance(u"com.sun.star.text.Footnote"_ustr)
.queryThrow<text::XTextContent>();
xText->insertTextContent(xText->getEnd(), xFootnote, false);
xFootnote.queryThrow<text::XText>()->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<frame::XStorable>()->storeToURL(
maTempFile.GetURL(),
{ comphelper::makePropertyValue(u"FilterName"_ustr, u"writer_pdf_Export"_ustr),
comphelper::makePropertyValue(u"FilterData"_ustr, aFilterData) });
std::unique_ptr<vcl::pdf::PDFiumDocument> 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<const char*>(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);
+36 -10
View File
@@ -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<pdf::IPDFEncryptor> 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<</Type/ObjStm/N "
+ OString::number(static_cast<sal_Int32>(m_aCompressedObjects.size())) + "/First "
+ OString::number(nFirst) + "/Length "
+ OString::number(static_cast<sal_Int32>(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("<</Names [\n");
for (auto const& it : ids)
{
@@ -1274,7 +1295,7 @@ sal_Int32 PDFWriterImpl::emitStructure( PDFStructureElement& rEle )
}
OStringBuffer aLine( 512 );
COSWriter aWriter(aLine, m_aContext.Encryption.getParams(), m_pPDFEncryptor);
COSWriter aWriter(aLine, m_aContext.Encryption.getParams(), getStringEncryptor(rEle.m_nObject));
aLine.append("<</Type");
sal_Int32 nParentTree = -1;
sal_Int32 nIDTree = -1;
@@ -4646,6 +4667,11 @@ bool PDFWriterImpl::emitCatalog()
emitStructure( m_aStructure[ 0 ] );
if (!emitObjectStream())
return false;
// whatever put its strings in plain has to have landed in an object stream; that the
// stream is encrypted is asserted where it is written
assert(std::ranges::all_of(m_aPlainStringObjects, [this](sal_Int32 nPlain) {
return m_aObjects[nPlain - 1].isCompressed();
}));
}
// adjust tree node file offset