Возьмите PDF счёта-фактуры, уже несущий шифрование AES-256, и попросите компонент PDFium для Delphi и C++Builder (PDFiumPas) проштамповать его как PDF/A для архивного хранения или подписать через PAdES посредством инкрементного обновления, а не полной перезаписи. Библиотека не пойдёт этим путём, подправляя зашифрованные байты напрямую: её шесть инжекторов маркеров соответствия обнаруживают существующую запись /Encrypt и пропускают источник в поток вывода побайтово неизменным, а её подписчик PAdES выбрасывает исключение вместо того, чтобы выпустить подпись, которую не примет ни один валидатор
Это иной вопрос, нежели аудит PDF, который вы не создавали, на предмет скрытого риска, что само по себе упражнение только для чтения. Эта статья — о стороне записи той же границы доверия: что вашему собственному коду позволено делать с файлом, чьи байты уже заперты за чьим-то чужим паролем, в момент, когда этот код пытается что-либо к нему добавить постфактум
Что требует ISO 32000-1 при обновлении зашифрованного PDF?
ISO 32000-1 §7.5.6 требует, чтобы трейлер инкрементного обновления повторял каждую запись из предыдущего трейлера, кроме /Prev, а таблица 15 перечисляет /Encrypt среди записей, которые может нести трейлер. Опустите её в новом трейлере, и у соответствующей спецификации программы чтения не будет причин усомниться в этом пропуске: новейший трейлер авторитетен, так что программа чтения, не находящая там /Encrypt, решает, что весь файл незашифрован, и пытается разобрать более старое, всё ещё зашифрованное тело как простые байты. Оставьте /Encrypt в новом трейлере, но запишите собственные объекты обновления как открытый текст, и отказ просто переместится на один шаг позже: программа чтения корректно обнаруживает шифрование, пропускает каждый затрагиваемый объект через шифр файла, включая новые, которые изначально никогда не шифровались, и получает обратно шум для содержимого, которое было совершенно читаемым до того, как его коснулась расшифровка. Любая из этих ошибок производит файл, выглядящий как обычное, корректно построенное инкрементное обновление на уровне байтов, — вплоть до того момента, когда его открывает соответствующая спецификации программа чтения
Шесть инжекторов маркеров, один барьер шифрования с v2.14.2
PDFiumPas поставляется с шестью инжекторами маркеров на уровне байтов, по одному на каждое подмножество PDF по стандартам ISO, которое он может пометить: PDF/A (ISO 19005), PDF/X (ISO 15930), PDF/UA (ISO 14289-1), PDF/E-1 (ISO 24517-1), PDF/R-1 (ISO 23504-1) и PDF/VT-1 (ISO 16612-2). Каждый берёт байты, уже записанные собственным FPDF_SaveAsCopy из PDFium, и надстраивает поверх них второе, меньшее инкрементное обновление: новый поток метаданных XMP, правку словаря каталога, указывающую на него, а для профилей, ориентированных на печать, — OutputIntent и ICC-профиль. Начиная с v2.14.2, каждая из InjectPdfAMarkers, InjectPdfXMarkers, InjectPdfUaMarkers, InjectPdfEMarkers, InjectPdfRMarkers и InjectPdfVTMarkers сначала читает исходный трейлер, и если он сообщает о существующей записи /Encrypt, копирует источник в целевой поток без изменений и немедленно возвращает управление. Никакого XMP, никакого OutputIntent, никакой правки каталога — вызывающий код получает обратно оригинальный файл, байт в байт
var
Src, Dst: TFileStream;
Opts: TPdfXSaveOptions;
begin
Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
try
Opts.Conformance := pxc4;
InjectPdfXMarkers(Src, Dst, Opts);
// pdfx-attempt.pdf is byte-identical to the source: still encrypted,
// no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
// nothing was corrupted either
finally
Dst.Free;
Src.Free;
end;
end;
Разрешение быть зашифрованным — не то же самое, что безопасность инъекции
PDF/E-1 и PDF/R-1 оба явно допускают, чтобы их принимающий документ был зашифрован на уровне спецификации, что читается как исключение, пока вы не посмотрите на то, что на самом деле должно произойти на диске. ISO 24517-1 §6.3 разрешает шифрование для PDF/E-1, а ISO 23504-1 §6.2.3 разрешает его для PDF/R-1 при условии, что заголовок объявляет %PDF-2.0. Ни один из пунктов ничего не говорит о том, может ли постобработчик на уровне байтов безопасно добавить объект с открытым текстом в этот зашифрованный контейнер, — а он не может, по тем же причинам §7.5.6, что применимы к любому другому подмножеству. Собственные валидаторы соответствия PDFiumPas для этих двух профилей, ValidatePdfECompliance и ValidatePdfRCompliance, намеренно фиксируют наличие /Encrypt, не помечая это как дефект, что корректно для валидатора только для чтения, никогда не пишущего ни байта. Это также лёгкий паттерн, мимо которого можно пробежать взглядом и предположить, что родственному инжектору не нужна отдельная защита, тогда как именно инжектор — та единственная функция в паре, которой действительно нужно отказать
Молча ли SaveAsPdfX расшифровывает ваш документ?
Да, всякий раз, когда вы идёте через публичные методы удобства, а не вызываете инжектор напрямую. Каждый из TPdf.SaveAsPdfA, SaveAsPdfX, SaveAsPdfUa, SaveAsPdfE, SaveAsPdfR и SaveAsPdfVT отрисовывает текущий документ во временный поток через SaveAs(Tmp, saRemoveSecurity) прежде, чем передать эти байты соответствующему инжектору. saRemoveSecurity отображается на собственный флаг FPDF_REMOVE_SECURITY в PDFium, так что временная копия, которую получает инжектор, изначально никогда не была зашифрована, и у защиты /Encrypt инжектора не остаётся ни одной причины сработать. Вывод несёт ваши маркеры PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 или PDF/VT-1, но больше не защищён тем паролем, что открывал источник
Этот компромисс невидим, пока кто-то ниже по потоку не откроет «защищённую» архивную копию без пароля и не заметит, что она просто работает. Исправление — не другой вызов метода; у PDFiumPas нет пары saAddSecurity к saRemoveSecurity, потому что лежащий в основе движок PDFium никогда не создавался для записи нового шифрования, только для его удаления. Если оба свойства важны для одного файла, шифрование должно быть отдельным шагом, которым владеете вы, применяемым после маркеров соответствия, а не встроенным в тот же вызов SaveAsPdfA
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'open-secret'; // needed to open the source at all
Pdf.FileName := 'signed-encrypted-invoice.pdf';
Pdf.LoadDocument;
Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
// invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
// ran first inside SaveAsPdfA: the output opens without a password
finally
Pdf.Free;
end;
end;
Что происходит при попытке подписать зашифрованный PDF через PAdES?
PDFiumPas прямо отказывает, а не молча отбрасывает запрос, как делает инжектор маркеров. И TPdf.SignPades, и SignPadesToStream оба направляются через внутренний SignPadesBytes, и первое, что он делает после разбора исходного трейлера, — проверяет наличие /Encrypt. Если запись присутствует, он выбрасывает EPadesCrypto с сообщением «SignPadesBytes: исходный документ зашифрован; удалите шифрование перед подписанием», не продолжая дальше. InjectPadesDssMarkers — функция, встраивающая сертификаты, ответы OCSP и списки отзыва CRL для долгосрочной проверки, — применяет ту же проверку по той же причине, с собственным сообщением: «InjectPadesDssMarkers: исходный документ зашифрован; удалите шифрование перед встраиванием материала проверки DSS»
Обоснование здесь строже, чем сквозной пропуск в инжекторах маркеров, и намеренно так. Молчаливый сквозной пропуск безопасен для штампа PDF/A, потому что его пропуск оставляет вас с тем же корректным PDF, с которого вы начали, просто без маркировки. Подписание не может провалиться так же тихо: подпись, что молча так никогда и не была добавлена, для любого вызывающего кода, проверяющего только булев результат, выглядит в точности как подпись, добавленная успешно. EPadesCrypto происходит от обычного класса Exception, так что его перехват — обычная обработка исключений, а не особое соглашение потока управления, которое нужно изучить
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'open-secret';
Pdf.FileName := 'encrypted-contract.pdf';
Pdf.LoadDocument;
try
Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
except
on E: EPadesCrypto do
// E.Message: 'SignPadesBytes: the source document is encrypted;
// remove encryption before signing'
raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
end;
finally
Pdf.Free;
end;
end;
Порядок штампов соответствия, подписей и шифрования
Практическое решение — это порядок, а не другая библиотека. Сначала примените маркеры PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 или PDF/VT-1, затем добавьте любую подпись PAdES, и только потом запустите тот шаг вашего конвейера, что реально владеет шифрованием, будь то выделенный писатель PDF, устройство подписания или ваша собственная реализация AES. Слой инкрементных обновлений PDFiumPas естественно вписывается в середину этой последовательности, дописывая небольшие, целевые объекты в файл, во всём остальном уже законченный, а шифрование принадлежит концу именно потому, что это единственная операция в цепочке, которую сам PDFiumPas не может ни выполнить, ни отменить
Ничто из этого не меняет то, как PDFiumPas читает данные трейлера и перекрёстных ссылок, от которых зависит каждое инкрементное обновление, что само по себе источник тонкостей, как только в картину входят потоки xref; статья о проверке потоков объектов и xref в PDF описывает, как этот же путь чтения трейлера обрабатывает сжатые структуры PDF 1.5+. А когда документ готов к чему-то более серьёзному, чем штамп соответствия, статья о подписании PDF подписью PAdES B-B в Delphi — это то место, где SignPades подхватывает ровно с той точки, где заканчивается эта статья
Описанные здесь инжекторы маркеров и методы SignPades поставляются как часть компонента PDFium для Delphi и C++Builder, наряду с рендерингом и инспекцией только для чтения, которые PDFium предоставляет нативно