Техническая статья

FDF Differences в Delphi: добавление подписанных редакций

HotPDF Component применяет поток FDF /Differences из Delphi через THPDFHeadlessDocument.SaveFDFDifferencesToFile: метод проверяет, что загруженный PDF всё ещё совпадает с базовой редакцией, которую вы задаёте точной длиной в байтах и SHA-256, добавляет накопительные байты редакции ровно один раз, прогоняет результат через проверки разрешений шифрования и допуска DocMDP/FieldMDP и публикует его атомарно. Повтор той же подачи — no-op с точностью до байта

Сценарий, ради которого это существует, непрестижный и ходовой. Вы отправляете договор или акт осмотра как PDF, кто-то заполняет его и подписывает в своём вьювере, и обратно приходит не PDF, а FDF-файл. Значения полей едут в /Fields, но сама подписанная редакция, байты, которые реально покрывает CMS-подпись, едет в /Differences. Ошибётесь со слиянием — либо сломаете подпись, либо, что хуже, примете значения полей, которые не покрывала ни одна подпись

Что поток FDF /Differences несёт на самом деле?

Поток FDF /Differences несёт сырые байты инкрементального обновления для целевого PDF, а не историю уровня FDF. ISO 32000-1 §12.7.7.3, Table 243 определяет запись Differences словаря FDF как инкрементальные изменения исходного PDF, сделанные с момента его открытия, в той же форме, что и PDF-инкрементальное обновление по §7.5.6: новые и заменённые объекты, секция перекрёстных ссылок и trailer, чей /Prev указывает обратно в базовый файл

Сам контейнер FDF — противоположность. §12.7.7.1 запрещает инкрементальные обновления FDF-файлов, поэтому у FDF единственный trailer, только объекты поколения 0 и уникальные номера объектов. HotPDF Component требует ровно этого в своём FDF-парсере и никогда не расслабляет это ради удобства /Differences. Инкрементальные данные живут внутри одного потока строго неинкрементального контейнера, и держать эти два слоя порознь — половина работы

Анатомия FDF /Differences в HotPDF для Delphi: строго неинкрементальный FDF-контейнер с единственным trailer и объектами поколения 0 несёт один накопительный поток /Differences из сырых байтов инкрементального обновления, тогда как /Fields и /Annots остаются неподписанными и несмёрженными
Подписанная редакция едет в потоке /Differences, а не в /Fields: сам контейнер запрещает инкрементальные обновления, и доверять можно только байтовую нагрузку, которую покрывает CMS-подпись

Два свойства потока определяют каждое решение дальше по цепочке:

  • Поток накопителен: он держит каждый байт, добавленный с момента открытия целевого PDF, поэтому вторая подача из той же сессии повторяет уже зафиксированную вами редакцию и добавляет новую за ней
  • Внешние записи /Fields и /Annots того же FDF не обязаны совпадать с /Differences, поэтому значения полей, которые легко прочитать, — не те данные, которые покрывает подпись

Второй пункт заслуживает прямоты. Если ваш импортирующий код читает /V из /Fields и считает результат подписанным потому, что в том же файле пришла подпись, вы построили канал подделки. HotPDF Component нарочно держит пути раздельно: FDF-импортёр аннотаций отвергает /Differences начисто, и только специальный транспорт Differences его трогает

Привязка добавки к явно указанной базовой редакции

SaveFDFDifferencesToFile отказывается угадывать, к какой редакции принадлежит FDF. Вызывающий передаёт BaseBytes — точную длину полной PDF-редакции, с которой была открыта форма, — и BaseSHA256, её 64-значный шестнадцатеричный SHA-256. Метод заново хеширует этот префикс загруженного документа, отвергает несовпадение с "The loaded PDF does not match the explicitly selected base" и проверяет, что префикс заканчивается на настоящем конце редакции, чей startxref указывает на его реальные данные перекрёстных ссылок

Практическое правило: записывайте оба числа, когда отправляете форму, а не когда FDF вернулся. Небольшого хелпера над подпрограммами SHA256Init / SHA256Update / SHA256Final в HPDFCrypt достаточно:

uses
  SysUtils, Classes, HPDFCrypt;

function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
  Stream: TFileStream;
  Ctx: TSHA256Context;
  Digest: TSHA256Digest;
  Buffer: array[0..65535] of Byte;
  Remaining: Int64;
  Count, I: Integer;
begin
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
      raise EArgumentException.Create('Base length is outside the file');
    SHA256Init(Ctx);
    Remaining := PrefixBytes;
    while Remaining > 0 do
    begin
      Count := SizeOf(Buffer);
      if Remaining < Count then
        Count := Integer(Remaining);
      Stream.ReadBuffer(Buffer, Count);
      SHA256Update(Ctx, Buffer, Count);
      Dec(Remaining, Count);
    end;
    SHA256Final(Ctx, Digest);
  finally
    Stream.Free;
  end;
  Result := '';
  for I := 0 to High(Digest) do
    Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;

// В момент отправки: весь исходящий файл — базовая редакция
// BaseLength := размер 'out\contract-0042.pdf' в байтах
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

Загруженный документ не обязан быть ровно базой. Это может быть база плюс редакции, которые вы уже зафиксировали из предыдущих подач, — так накопительный поток остаётся управляемым: BaseBytes продолжает называть исходный префикс, а всё после него трактуется как ранее добавленные байты

Байтовый расклад FDF Differences в HotPDF для Delphi: загруженный PDF делится на базовую редакцию, проверяемую длиной BaseBytes и BaseSHA256, плюс уже зафиксированные ранее добавленные байты, и накопительную нагрузку /Differences, которая обязана повторить эти байты, прежде чем добавить новый суффикс
Базовый префикс приколочен длиной и SHA-256, зафиксированные байты действуют как контракт только-добавляй, а новым является лишь хвостовой суффикс — повторная подача тогда ложится как no-op с точностью до байта

Применение Differences через SaveFDFDifferencesToFile

THPDFHeadlessDocument.SaveFDFDifferencesToFile принимает имя выходного файла, байты FDF, длину и хеш базы, необязательный выбор вложенного FDF и лимитную запись THPDFFDFPackageOptions, а возвращает THPDFHeadlessFDFDifferencesResult. Внутри он открывает FDF как THPDFFDFPackage и сам вызывает DecodeDifferences, так что вы передаёте FDF-файл, а не заранее декодированный поток. Пустой Selection означает верхнеуровневый FDF; непустой сперва спускается во вложенный дочерний FDF

uses
  SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;

procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
  BaseLength: Int64; const BaseHash: AnsiString);
var
  Doc: THPDFHeadlessDocument;
  Policy: THPDFHeadlessCMSOptions;
  Selection: THPDFFDFChildSelections;
  Applied: THPDFHeadlessFDFDifferencesResult;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Policy := THPDFHeadlessCMSOptions.Default;
    Policy.TrustedCAFile := 'trust\issuing-ca.pem';
    Doc.SetSignaturePolicyOptions(Policy);
    Doc.LoadFromFile(CurrentPdf);

    Selection := nil;  // верхнеуровневый FDF, без вложенного потомка
    Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
      TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
      Selection, THPDFFDFPackageOptions.Default);

    Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
      [Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
       Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
  finally
    Doc.Free;
  end;
end;

Порядок проверок важнее самого вызова. После прохода хеша базы метод сравнивает уже зафиксированные добавленные байты — всё между BaseBytes и концом загруженного файла — с началом декодированной нагрузки. Подача, короче того, что вы уже зафиксировали, проваливается с "A cumulative submission cannot remove committed append bytes", а расходящаяся с ним — с "The cumulative Differences conflict with the committed revision". Новым является только оставшийся суффикс, и только он попадает в AddedBytes

Конвейер проверок SaveFDFDifferencesToFile в HotPDF для Delphi: длина и SHA-256 базы, сравнение накопительного префикса, валидация временного кандидата, идентичность /ID и подписей, биты разрешений плюс допуск DocMDP и FieldMDP, затем атомарная публикация добавленной редакции
Шесть fail-closed ворот идут в фиксированном порядке, прежде чем хоть байт добавленной редакции будет опубликован, поэтому кривой FDF или недопустимое изменение оставляют загруженный документ и существующий вывод нетронутыми

Собранный кандидат пишется во временный файл с монопольным доступом в целевом каталоге, сбрасывается на диск, заново открывается как независимый источник редакции и валидируется, прежде чем заменить вывод. Сбои разбора, отмены, политики, бюджета или публикации оставляют и загруженный документ, и любой существующий выходной файл нетронутыми. Два предусловия ловят людей рано: вывод не должен совпадать с загруженным источником, а в документе не должно быть несохранённых правок, потому что непрозрачная добавка молча их выбросит ("Publish pending document edits before applying Differences")

Почему повторное применение — no-op с точностью до байта?

Повторное применение того же накопительного FDF — no-op, потому что каждый байт его нагрузки уже в файле. Когда нагрузка равна ранее добавленным байтам в точности, SaveFDFDifferencesToFile сообщает NoOp = True и AddedBytes = 0, а публикуемый вывод идентичен текущему PDF. Это гарантия сильнее, чем «только добавляй», и именно она делает безопасными повторы после сетевого таймаута или падения воркера

// Редакция 1 уже зафиксирована в contract-0042-r1.pdf; тот же FDF приходит дважды
// (Doc, Again, Fdf, Selection, BaseLength и BaseHash объявлены выше)
Doc := THPDFHeadlessDocument.Create;
try
  Doc.LoadFromFile('store\contract-0042-r1.pdf');
  Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
    Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
  // Again.NoOp = True, Again.AddedBytes = 0,
  // а retry-файл побайтово идентичен contract-0042-r1.pdf
finally
  Doc.Free;
end;

Приёмочные тесты пишите под это. Утверждайте полное побайтовое равенство между retry-выводом и текущей редакцией, а не просто то, что файл не похудел. Тест, проверяющий лишь поведение «только добавляй», с радостью пропустит реализацию, добавляющую ту же редакцию второй раз, — она даёт правдоподобный на вид PDF с дублированной секцией обновления, которую потом приходится объяснять аудиту DocMDP

Какие проверки допуска идут до публикации редакции?

До публикации кандидат PDF должен пройти проверки идентичности, подписей и разрешений, и ни одна из них не доверяет внешним полям FDF. Первый элемент trailer /ID обязан сохраниться без изменений, и каждое поле подписи, уже присутствующее в загруженном документе, обязано существовать с идентичным словарём подписи и той же идентичностью значения подписи. Нагрузка Differences, удаляющая, заменяющая или переписывающая существующую подпись, проваливается с hdSignatureModificationUnsupported

Дальше идут слои авторизации. Для непустой добавки в зашифрованном документе изменённые объекты классифицируются по роли (каталог и страницы, аннотации, поля AcroForm, appearance stream) и сверяются с аутентифицированными битами разрешений пользователя, поэтому редакция, правящая аннотации, нуждается в разрешении на аннотации, и так далее. Затем кандидат проходит то же принуждение сертификации и блокировок полей, которое HotPDF Component применяет и в других местах: сертификационная подпись DocMDP (ISO 32000-1 §12.8.2.2) ограничивает, какие виды изменений могут делать последующие редакции, а блокировки FieldMDP (§12.8.2.4) защищают именованные поля. Анализ редакций DocMDP и FieldMDP в HotPDF рассказывает, как эти разрешения классифицируются по редакциям

Обработку ошибок стоит разделить на «этот FDF кривой» и «эта редакция не разрешена», потому что второй случай обычно требует человека:

try
  Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
    Selection, THPDFFDFPackageOptions.Default);
except
  on E: EHPDFFDFPackageError do
    QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
  on E: EHPDFHeadlessDocumentError do
    case E.Code of
      hdPermissionDenied, hdSignatureModificationUnsupported:
        EscalateForReview(FdfFile, E.Message);
      hdLimitExceeded:
        QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
    else
      QuarantineFdf(FdfFile, E.Message);  // несовпадение базы, конфликт, плохой конец
    end;
end;

Чего операция никогда не делает, так же важно, как то, что она проверяет. SaveFDFDifferencesToFile никогда не забирает файловую спецификацию /F из FDF, поэтому PDF, названный внутри FDF, не может перенаправить слияние на другой файл на диске или в сети, и она никогда не исполняет JavaScript или actions. Каждый вход приходит явно: загруженная база, байты FDF и настроенный вами материал доверия

Транзакции Windows и C ABI fdf.differences.apply

Тот же транспорт доступен и без прямого написания headless-кода. THPDFWindowsFormDataTransaction.ApplyFDFDifferences принимает байты FDF, длину и SHA-256 базы, выбор, опции пакета и пароль, делегирует SaveFDFDifferencesToFile и добавляет байтовый учёт, откат, отмену и поведение повтора на том же хендле, присущие транзакции. Для недельфийских вызывающих C ABI выставляет JSON-операцию fdf.differences.apply, где base несёт длину и хеш:

{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
  "base": { "bytes": 673, "sha256": "..." },
  "signaturePolicy": { "trustedCAPEM": "..." } }

JSON-ответ сообщает baseBytes, priorAppendBytes, totalAppendBytes, addedBytes и noop — те же поля, что и THPDFHeadlessFDFDifferencesResult. Опубликованные приёмочные ворота этого транспорта гоняются на Linux FPC 3.3.1, Windows Delphi 32- и 64-бит и Windows FPC 32- и 64-бит; каждая цель безопасности покрывает 21 случай с 1 244 нативными проверками и 382 командами колбэков

Пределы и грабли фикстур, о которых стоит знать

Транспорт Differences нарочно узок, и пара его краёв уже кусала реальные наборы тестов:

  • База обязана быть полной PDF-редакцией. Длина, попавшая в середину файла, или чей startxref не указывает на её реальные данные перекрёстных ссылок, отвергается раньше, чем будет скопирован хоть байт
  • Подписным фикстурам нужен косвенный /Lock. Читатель разрешений загруженного документа в Windows отвергает поле подписи, чей /Lock — встроенный словарь, с "Signature field /Lock must be an indirect reference", хотя некоторые пути подписания прощают встроенную форму, так что пишите словарь блокировки собственным объектом
  • Тестовый CA, сделанный голым openssl req -x509, не имеет CA:TRUE и keyCertSign, поэтому валидация цепочки проваливается с certificate verify error, похожим на баг библиотеки, но им не являющимся
  • Внешние значения /Fields этот вызов не смёржит. Если они вам тоже нужны в документе, решите явно, информационные они или нет, и никогда не подавайте их как подписанные
  • Сам FDF остаётся неинкрементальным. FDF-файл с несколькими trailer, ненулевыми поколениями или дублированными номерами объектов — кривой FDF, а не более богатый

Для простой стороны значений FDF и XFDF, без подписанных редакций в кадре, лучшими стартовыми точками будут туда-обратно multi-select FDF и XFDF в HotPDF и импорт и экспорт аннотаций XFDF в Delphi

FAQ

Можно передать декодированный поток Differences вместо FDF-файла?

Нет. SaveFDFDifferencesToFile разбирает переданный FDF как THPDFFDFPackage, применяет необязательный выбор вложенного и вызывает DecodeDifferences внутри. Используйте THPDFFDFPackage.DecodeDifferences отдельно, когда хотите осмотреть или заархивировать сырые байты редакции, но в вызов применения передавайте FDF-файл

Что будет, когда клиент шлёт второй, больший накопительный FDF?

Загрузите PDF, уже содержащий первую зафиксированную редакцию, и держите те же BaseBytes и BaseSHA256. Метод проверит, что новая нагрузка начинается ровно с уже зафиксированных байтов, добавит только новый суффикс, сообщит его в AddedBytes, а затем прогонит полную проверку разрешений и допуск DocMDP/FieldMDP по результату

Значит ли успешное применение, что значения в /Fields подписаны?

Нет. ISO 32000-1 не требует, чтобы /Fields, /Annots и /Differences в одном FDF совпадали, и HotPDF Component их не сверяет. Подписано то, что покрывают CMS-подписи в добавленной редакции, поэтому подписанные значения читайте из смёрженного PDF, а не из FDF-обёртки

Транспорт FDF Differences, headless-движок политик подписи и транзакции form-data Windows поставляются в составе HotPDF Component для Delphi, C++Builder и Free Pascal