PDFlibPas отвечает на вопрос FieldMDP через TPDFlib.GetSignatureModificationVerdict. Метод принимает подписанный PDF, его пароль и имя поля подписи, проходит по всем инкрементальным обновлениям, добавленным после этой подписи, разрешает полное имя каждого поля формы, затронутого этими обновлениями, и возвращает 3, если изменилось поле, заблокированное FieldMDP, и 2, если тронуты только незаблокированные поля. GetSignatureModificationDetail затем называет редакцию и поле
Сценарий, которому это нужно, — форма с несколькими согласующими. Инициатор заполняет Order.Customer и Order.Total в заявке на закупку и подписывает Approval.Requester, блокируя эти два поля. Менеджер добавляет центр затрат и подписывает Approval.Manager. Две недели спустя кто-то открывает файл в настольном редакторе и подтягивает Order.Total вверх в третьем инкрементальном обновлении. Каждая подпись по-прежнему валидна, потому что каждая покрывает только собственный byte range. Вопросы, которые на деле задаёт аудитор: какое поле изменилось после какой подписи и было ли это изменение разрешено
Почему одного DocMDP недостаточно, чтобы поймать правку заблокированного поля?
DocMDP её не ловит, потому что DocMDP классифицирует виды изменений, а не отдельные поля. Уровни разрешений сертификации в ISO 32000-1 §12.8.2.2 говорят, могут ли последующие обновления заполнять формы и подписывать (P 2) или ещё и добавлять аннотации (P 3), и это разрешение действует на все поля документа одинаково. Правка Order.Total — это заполнение формы, так что сертификация P 2 полностью удовлетворена той самой правкой, которую инициатор хотел запретить. Классификация изменений на уровне документа в PDFlibPas покрывает этот слой, а эта статья спускается на шаг глубже
FieldMDP — слой с точностью до поля. Поле подписи может нести словарь /Lock (ISO 32000-1 §12.7.4.5), а хранимая им подпись — transform FieldMDP в signature reference (ISO 32000-1 §12.8.2.4). Оба используют один словарь понятий: /Action /All блокирует все поля, /Action /Include блокирует имена из /Fields, а /Action /Exclude блокирует всё, кроме имён в /Fields. До v3.586.0 GetSignatureModificationVerdict знал, что более поздняя редакция изменила какое-то поле формы, но не знал какое, поэтому любой документ с блокировкой поля, чьи последующие обновления вышли за пределы материала валидации, возвращался с кодом 4 — неопределённо. Карта редакций теперь записывает разрешённое имя поля для каждого классифицируемого ею объекта, связанного с полями, — именно это превращает ту 4 в настоящую 2 или 3
Запись блокировки поля при подписи каждого согласующего
TPDFlib.SetSignProcessFieldLock задаёт блокировку для следующей подписи, которую произведёт процесс подписания. Action 1 — All, 2 — Include, 3 — Exclude, а 0 снимает блокировку; Include и Exclude принимают список полных имён полей через точку с запятой. Подписываемое поле в итоге получает словарь /Lock и соответствующий transform FieldMDP — именно эту пару ждут строгие валидаторы
var
Pdf: TPDFlib;
SignId: Integer;
begin
Pdf := TPDFlib.Create;
try
SignId := Pdf.NewSignProcessFromFile('purchase-request.pdf', '');
if SignId = 0 then
raise Exception.Create('cannot open the form');
try
Pdf.SetSignProcessField(SignId, 'Approval.Requester');
Pdf.SetSignProcessPFXFromFile(SignId, 'requester.pfx', PfxPassword);
// 2 = Include: эта подпись замораживает только перечисленные поля
if Pdf.SetSignProcessFieldLock(SignId, 2,
'Order.Customer;Order.Total') <> 1 then
raise Exception.Create('field lock rejected');
Pdf.EndSignProcessToFile(SignId, 'purchase-request-signed.pdf');
if Pdf.GetSignProcessResult(SignId) <> 1 then
raise Exception.CreateFmt('signing failed, code %d',
[Pdf.GetSignProcessResult(SignId)]);
finally
Pdf.ReleaseSignProcess(SignId);
end;
finally
Pdf.Free;
end;
end;
SetSignProcessFieldLock возвращает 0 для вызова Include или Exclude без имён, а также для списка с пустым элементом вроде 'Order.Customer;;Order.Total'; отклонённый вызов оставляет ранее заданную блокировку на месте, поэтому проверяйте возвращаемое значение, а не надейтесь, что последний вызов победил. Остальная часть настройки подписания — выбор дайджеста, резервирование /Contents и коды результатов — описана в руководстве по подписанию и валидации PAdES в PDFlibPas. Один момент проектирования специфичен для цепочек согласования: блокировка /All на подписи инициатора замораживает и Approval.Manager, потому что поле подписи — тоже поле формы. Для полей данных, которыми владеет каждый согласующий, используйте Include, либо Exclude с перечисленными полями будущих подписей
Прогон вердикта по цепочке согласования
GetSignatureModificationVerdict судит одну подпись за раз, поэтому цепочка согласования проверяется вызовом для каждого поля подписи. Возвращаемое значение — маленькое замкнутое множество, и каждое значение значит для проверяющего своё:
- 0: файл или указанное поле подписи не удалось прочитать
- 1: после подписи ничего не добавлялось
- 2: добавлены только изменения, которые разрешают действующие разрешения, и ни одно заблокированное поле не тронуто
- 3: более поздняя редакция внесла недопустимое изменение, включая правку поля, заблокированного FieldMDP
- 4: ответ установить не удалось, и его ни в коем случае нельзя читать как одобрение
- 5: подпись не заканчивается на границе редакции, поэтому байты лежат внутри диапазона, который она заявляет к покрытию
const
Approvals: array[0..2] of WideString =
('Approval.Requester', 'Approval.Manager', 'Approval.Finance');
var
Pdf: TPDFlib;
I, Verdict: Integer;
begin
Pdf := TPDFlib.Create;
try
for I := Low(Approvals) to High(Approvals) do
begin
Verdict := Pdf.GetSignatureModificationVerdict(
'purchase-request-final.pdf', FormPassword, Approvals[I]);
// оба геттера описывают только что сделанный вызов, читайте их до следующего
Log.Add(Format('%s: verdict %d, MDP level %d, %s',
[Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
Pdf.GetSignatureModificationDetail]));
case Verdict of
1, 2: ; // принято
3: RejectForm(Approvals[I]); // в detail названы поле и редакция
else
QueueForManualReview(Approvals[I]); // 0, 4, 5
end;
end;
finally
Pdf.Free;
end;
end;
Возьмите подделанную заявку на закупку, где редакция 0 — пустая форма, редакции 1 и 2 — две подписи, а редакция 3 — правка. Поскольку определения блокировок собираются в пул со всех подписанных полей, вердикт для Approval.Manager уже учитывает блокировку инициатора, и его единственное более позднее обновление — сама правка. Когда это обновление переписало только словарь поля, вердикт равен 3, а detail читается как Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow. Эта одна строка отвечает на оба вопроса аудита. Вердикту для Approval.Requester нужно сначала пройти подпись менеджера, и по причинам из раздела об ограничениях ниже он часто выдаст 4, так что самая свежая подпись обычно даёт самый точный ответ. GetSignatureEffectiveMDPLevel сообщает наименьшее значение DocMDP P среди найденных подписей или 3, когда документ не сертифицировал никто, и оба геттера остаются валидными только до следующего вызова вердикта
Как PDFlibPas узнаёт, какое поле изменила редакция?
PDFlibPas восстанавливает имя поля из графа объектов каждой редакции, потому что изменённый объект редко несёт своё полное имя. Словарь поля с /FT или /V, как и widget-аннотация, хранит только своё частичное имя в /T. Полное имя из ISO 32000-1 §12.7.3.2 — это цепочка частичных имён от корня, соединённых точками, поэтому карта редакций идёт по ссылкам /Parent вверх и дописывает каждый найденный /T в начало. Каждый родитель читается из его новейшего определения на редакцию классификации или раньше, что не даёт переименованию родительского узла переписать историю более ранних правок
Сверка разрешённого имени с блокировками нарочно проста. Имя совпадает с элементом /Fields, когда оно равно ему точно или начинается с него и точки, поэтому блокировка Order блокирует и Order.Total, и всех прочих потомков. All блокирует все имена, Include — совпавшие имена, Exclude — все имена, которые не совпали. Определения блокировок берутся со всех подписанных полей подписи в файле; если у поля есть и словарь /Lock, и transform FieldMDP, используются значения /Lock. Чтобы увидеть, что именно заблокировала каждая подпись, низкоуровневый класс TPDFlibSignDoc из модуля PDFlibDigitalSign отдаёт отчёт о разрешениях, который читает вердикт:
uses
PDFlibDigitalSign;
var
SignDoc: TPDFlibSignDoc;
Report: TPDFlibSignaturePermissionReport;
Names: TStringList;
I, J: Integer;
Line: string;
begin
SignDoc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not SignDoc.Open('purchase-request-final.pdf', FormPassword, False) then
Exit;
try
SignDoc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
begin
SignDoc.GetSignaturePermissionInfoByName(Names[I], Report);
if not (Report.HasFieldLock or Report.HasFieldMDP) then
Continue;
Line := Names[I] + ': ' + string(Report.FieldLockAction);
for J := 0 to High(Report.FieldNames) do
Line := Line + ' ' + string(Report.FieldNames[J]);
if Report.HasFieldLock and Report.HasFieldMDP and
not Report.FieldMDPMatches then
Line := Line + ' (lock and FieldMDP transform disagree)';
Log.Add(Line);
end;
finally
SignDoc.Close;
end;
finally
Names.Free;
SignDoc.Free;
end;
end;
Зашифрованные формы: передавайте пароль, иначе имена нечитаемы
Зашифрованную форму нужно расшифровать, прежде чем разрешить хоть какое-то имя поля, поэтому GetSignatureModificationVerdict нужен аргумент Password не только для открытия файла. Строки вроде /T шифруются пообъектно, а поля, записанные в сжатые object stream, вообще нечитаемы, пока содержащий их поток не расшифрован и не распакован. С v3.586.0 внутренняя карта редакций принимает пароль, подключает контекст расшифровки при разборе object stream и словарей редакций и расшифровывает каждый декодируемый объект, включая более ранние версии, с которыми он сравнивает, и родителей, по которым поднимается
Пообъектная расшифровка в карте редакций включается только при непустом Password. Поэтому вердикт, вычисленный по зашифрованному файлу с пустым паролем, следует считать неподтверждённым, даже если сам документ открылся без запроса пароля. Храните пароль формы рядом с записью о бизнес-процессе и передавайте его при каждом вызове
Чего вердикт FieldMDP не покрывает
Проверка полей — структурный ответ на один узкий вопрос, и несколько случаев остаются за его пределами. Знание их заранее — то, что не даёт вердикту 4 превратиться в эскалацию в поддержку:
- Сравнение полей запускается только после того, как проверка уровня документа прошла чисто и хотя бы одна более поздняя редакция изменила больше, чем материал валидации или метки времени; нарушение DocMDP сообщается как 3 раньше, чем хоть одно поле будет рассмотрено
- Объекты, классифицированные как связанные с подписью, но не несущие имени поля, — словарь значения подписи, переписанный словарь AcroForm или изменение каталога, ограниченное
/AcroFormили/Perms, — заставляют вердикт вернуть 4 вместо догадки, поэтому редакция, добавляющая подпись следующего согласующего под активной блокировкой, часто получает 4 - Имя поля получают только словари полей и widget-аннотации; у карты редакций нет отдельного класса для appearance stream, поэтому редактор, перегенерирующий внешние виды вместе со значением, может быть остановлен проверкой уровня документа как изменение содержимого раньше проверки полей, и строка detail различает эти два случая
- Сканирование останавливается на первом встреченном заблокированном поле или безымянном элементе, поэтому 3 называет одно виновное поле, а не все
- Блокировки собираются в пул со всех подписанных полей подписи без упорядочивания по времени, поэтому блокировка, записанная более поздним подписантом, действует и на обновления, случившиеся до неё; это может сдвинуть результат только к 3, никогда к 2
- Имена сравниваются точно и с учётом регистра, а подъём по
/Parentостанавливается после восьми уровней, так что необычно глубокая иерархия разрешается в усечённое имя - Элемент FieldMDP, чья структура не валидна и чьё действие нечитаемо, заставляет вердикт вернуть 4
Когда вердикт возвращается как 4, GetSignatureRevisionIndex даёт редакцию, на которой заканчивается подпись, а GetDocumentRevisionChanges перечисляет каждый объект, который каждая более поздняя редакция добавила, изменила или освободила, с его классом изменения. Этого человеку достаточно, чтобы решить за минуту. Механика добавления этих редакций описана в руководстве по инкрементальным обновлениям PDFlibPas
FAQ
Означает ли вердикт 2, что подписи валидны?
Нет. GetSignatureModificationVerdict изучает то, что было добавлено после подписи, и никогда не проверяет значение CMS-подписи, цепочку сертификатов или отзыв. Двойка говорит, что последующие обновления остались внутри разрешений; криптографическая валидация — отдельный шаг, и нужны оба
Почему вторая подпись согласующего даёт 4 вместо 2?
Подписание добавляет словарь значения подписи и обычно переписывает форменную бухгалтерию вроде словаря AcroForm. Эти объекты не принадлежат ни одному именованному полю, и при активной блокировке поля вердикт отказывается засчитывать изменение, которое не может приписать полю. Используйте GetDocumentRevisionChanges, чтобы убедиться, что редакция содержит только ожидаемые объекты подписи
Может ли одно имя заблокировать целую группу полей?
Да. Элемент блокировки совпадает с именованным полем и каждым потомком, чьё полное имя начинается с него и точки, поэтому Order в блокировке Include замораживает Order.Customer, Order.Total и любое другое поле под этим родителем
Вердикты FieldMDP, подписание с блокировкой полей и инспекция редакций входят в PDFlibPas Delphi PDF Library — вместе с сертификацией DocMDP и долгосрочной валидацией PAdES, на которые опирается описанный выше процесс согласования