Artículo técnico

FieldMDP en Delphi: detectar cambios en campos bloqueados

PDFlibPas responde a la pregunta de FieldMDP mediante TPDFlib.GetSignatureModificationVerdict. Dado un PDF firmado, su contraseña y el nombre de un campo de firma, el método recorre cada actualización incremental añadida después de esa firma, resuelve el nombre completo de cada campo de formulario que esas actualizaciones tocaron, y devuelve 3 cuando se cambió un campo bloqueado por FieldMDP y 2 cuando solo se tocaron campos sin bloquear. GetSignatureModificationDetail nombra además la revisión y el campo

El flujo que necesita esto es el formulario con varios aprobadores. Un solicitante rellena Order.Customer y Order.Total en una solicitud de compra y firma Approval.Requester, bloqueando esos dos campos. Un responsable añade un centro de coste y firma Approval.Manager. Dos semanas después alguien abre el fichero en un editor de escritorio y sube Order.Total en una tercera actualización incremental. Todas las firmas siguen verificando, porque cada una cubre solo su propio rango de bytes. Las preguntas que un auditor hace de verdad son qué campo cambió después de qué firma, y si ese cambio estaba permitido

Por qué DocMDP solo no detecta un campo bloqueado editado

DocMDP no puede detectarlo porque DocMDP clasifica tipos de cambio, no campos individuales. Los niveles de permiso de certificación de ISO 32000-1 §12.8.2.2 dicen si las actualizaciones posteriores pueden rellenar formularios y firmar (P 2) o también anotar (P 3), y ese permiso se aplica por igual a todos los campos del documento. Editar Order.Total es relleno de formularios, así que una certificación P 2 queda satisfecha exactamente por la edición que el solicitante quería prohibir. La clasificación de modificaciones a nivel de documento en PDFlibPas cubre esa capa, y este artículo baja un paso más fino

FieldMDP es la capa con granularidad de campo. Un campo de firma puede llevar un diccionario /Lock (ISO 32000-1 §12.7.4.5), y la firma que contiene puede llevar una transformación FieldMDP en su signature reference (ISO 32000-1 §12.8.2.4). Ambos usan el mismo vocabulario: /Action /All bloquea todos los campos, /Action /Include bloquea los nombres listados en /Fields, y /Action /Exclude bloquea todo excepto los nombres de /Fields. Antes de v3.586.0, GetSignatureModificationVerdict sabía que una revisión posterior había cambiado un campo de formulario pero no cuál, así que cualquier documento con un bloqueo de campo cuyas actualizaciones posteriores iban más allá de material de validación volvía como 4, indeterminado. El mapa de revisiones registra ahora un nombre de campo resuelto para cada objeto relacionado con campos que clasifica, y eso es lo que convierte ese 4 en un 2 o un 3 reales

Escribir el bloqueo de campo cuando firma cada aprobador

TPDFlib.SetSignProcessFieldLock fija el bloqueo para la siguiente firma que produzca un proceso de firma. La acción 1 es All, 2 es Include, 3 es Exclude y 0 limpia el bloqueo; Include y Exclude toman una lista de nombres completos de campos separados por punto y coma. El campo firmado acaba con un diccionario /Lock y una transformación FieldMDP que casa, que es la pareja que esperan los validadores estrictos

Códigos de acción de PDFlibPas SetSignProcessFieldLock para firma FieldMDP en Delphi: la acción 1 All bloquea todos los campos de formulario incluidos los campos de firma posteriores, la acción 2 Include bloquea los campos de datos nombrados, la acción 3 Exclude bloquea todo excepto la lista, y la acción 0 limpia el bloqueo
Elegid Include para los campos de datos que cada aprobador posee o Exclude con los campos de firma posteriores listados; una llamada rechazada devuelve 0 y deja el bloqueo anterior en pie, así que comprobad siempre el valor devuelto
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: solo estos campos quedan congelados por esta firma
      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 devuelve 0 para una llamada Include o Exclude sin nombres, y también para una lista con una entrada vacía como 'Order.Customer;;Order.Total'; una llamada rechazada deja el bloqueo configurado previamente en su sitio, así que comprobad el valor devuelto en lugar de asumir que la última llamada ganó. El resto de la configuración de firma, elección de digest, reserva de /Contents y códigos de resultado, está cubierto en la guía de firma y validación PAdES de PDFlibPas. Un punto de diseño es específico de las cadenas de aprobación: un bloqueo /All en la firma del solicitante también congela Approval.Manager, porque un campo de firma también es un campo de formulario. Usad Include para los campos de datos que cada aprobador posee, o Exclude con los campos de firma posteriores listados

Ejecutar el veredicto a lo largo de una cadena de aprobación

GetSignatureModificationVerdict juzga una firma cada vez, así que una cadena de aprobación se comprueba llamándolo una vez por campo de firma. El valor devuelto es un conjunto pequeño y cerrado, y cada valor significa algo distinto para quien revisa:

  • 0: el fichero o el campo de firma nombrado no se pudieron leer
  • 1: no se añadió nada después de la firma
  • 2: solo se añadieron cambios que los permisos vigentes permiten, y no se tocó ningún campo bloqueado
  • 3: una revisión posterior hizo un cambio no permitido, incluida una edición de un campo que FieldMDP bloquea
  • 4: la respuesta no se pudo determinar, algo que nunca debe leerse como aprobación
  • 5: la firma no termina en un límite de revisión, así que hay bytes dentro del rango que dice cubrir
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]);
      // ambos getters describen la llamada recién hecha, así que leedlos antes de la siguiente
      Log.Add(Format('%s: verdict %d, MDP level %d, %s',
        [Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
         Pdf.GetSignatureModificationDetail]));
      case Verdict of
        1, 2: ;                                 // aceptado
        3: RejectForm(Approvals[I]);            // el detalle nombra campo y revisión
      else
        QueueForManualReview(Approvals[I]);     // 0, 4, 5
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Tomad la solicitud de compra manipulada, donde la revisión 0 es el formulario en blanco, las revisiones 1 y 2 son las dos firmas y la revisión 3 es la edición. Como las definiciones de bloqueo se agrupan de todos los campos firmados, el veredicto para Approval.Manager ya aplica el bloqueo del solicitante, y su única actualización posterior es la propia edición. Cuando esa actualización reescribió solo el diccionario del campo, el veredicto es 3 y el detalle dice Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow. Esa única línea responde las dos preguntas de auditoría. El veredicto para Approval.Requester tiene que pasar primero por la revisión de firma del responsable, y por los motivos de la sección de limitaciones de abajo a menudo informará 4, así que la firma más reciente suele dar la respuesta más nítida. GetSignatureEffectiveMDPLevel informa del valor P de DocMDP más bajo encontrado entre las firmas, o 3 cuando nada certificó el documento, y ambos getters siguen siendo válidos solo hasta la siguiente llamada de veredicto

Cronología de actualizaciones incrementales de un formulario PDF Delphi manipulado leída por PDFlibPas GetSignatureModificationVerdict: la revisión 1 lleva un bloqueo Include sobre Order.Customer y Order.Total, la revisión 2 firma Approval.Manager, y la reescritura de la revisión 3 del campo bloqueado Order.Total produce veredicto 3 para la firma del responsable y 4 para la del solicitante
Los bloqueos se agrupan de todos los campos firmados, así que el veredicto para Approval.Manager ya aplica el bloqueo del solicitante y la cadena de detalle nombra el campo y la revisión infractores, mientras que la firma más antigua suele quedarse en 4

Cómo sabe PDFlibPas qué campo cambió una revisión

PDFlibPas reconstruye el nombre del campo desde el grafo de objetos de cada revisión, porque un objeto cambiado rara vez lleva su nombre completo. Un diccionario de campo con /FT o /V, o una widget annotation, solo contiene su nombre parcial en /T. El nombre completo descrito en ISO 32000-1 §12.7.3.2 es la cadena de nombres parciales desde la raíz, unidos con puntos, así que el mapa de revisiones recorre los enlaces /Parent hacia arriba y antepone cada /T que encuentra. Cada padre se lee de su definición más reciente en o antes de la revisión que se clasifica, lo que evita que un renombrado posterior de un nodo padre reescriba la historia de ediciones anteriores

Resolución del nombre completo FieldMDP en PDFlibPas para Delphi: el mapa de revisiones recorre los enlaces /Parent hacia arriba desde la widget cambiada, antepone cada nombre parcial /T unido con puntos para construir Order.Total, y lo compara con las entradas /Fields por igualdad exacta o prefijo con punto, de modo que un bloqueo sobre Order cubre todos los descendientes
Un objeto cambiado rara vez lleva su nombre completo: los padres se leen de su definición más reciente en o antes de la revisión clasificada, los nombres se comparan distinguiendo mayúsculas y el recorrido se detiene tras ocho niveles

Comparar el nombre resuelto con los bloqueos es deliberadamente simple. Un nombre casa con una entrada de /Fields cuando es igual a la entrada exactamente o empieza por la entrada seguida de un punto, así que bloquear Order bloquea también Order.Total y cualquier otro descendiente. All bloquea todos los nombres, Include bloquea los nombres que casan, y Exclude bloquea todos los nombres que no casan. Las definiciones de bloqueo vienen de todos los campos de firma firmados del fichero; cuando un campo tiene a la vez un diccionario /Lock y una transformación FieldMDP, se usan los valores de /Lock. Para ver exactamente qué bloqueó cada firma, la clase de nivel inferior TPDFlibSignDoc de la unidad PDFlibDigitalSign expone el informe de permisos que lee el veredicto:

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;

Formularios cifrados: pasad la contraseña o los nombres son ilegibles

Un formulario cifrado tiene que descifrarse antes de poder resolver cualquier nombre de campo, así que GetSignatureModificationVerdict necesita el argumento Password para algo más que abrir el fichero. Cadenas como /T se cifran por objeto, y los campos escritos en object streams comprimidos no se pueden leer en absoluto hasta que el stream contenedor se descifra e infla. Desde v3.586.0 el mapa de revisiones interno acepta la contraseña, adjunta un contexto de descifrado mientras analiza object streams y diccionarios de revisión, y descifra cada objeto que decodifica, incluidas las versiones anteriores contra las que compara y los padres que recorre

El descifrado por objeto del mapa de revisiones solo se activa cuando Password no está vacía. Un veredicto calculado sobre un fichero cifrado con una contraseña vacía debe tratarse por tanto como no verificado, aunque el documento en sí se abriera sin preguntar. Guardad la contraseña del formulario junto al registro del flujo y pasadla en cada llamada

Lo que el veredicto FieldMDP no cubre

La comprobación de campos es una respuesta estructural a una pregunta estrecha, y varios casos quedan fuera de ella. Conocerlos por adelantado es lo que evita que un 4 se convierta en una escalada de soporte:

  • La comparación de campos corre solo después de que la comprobación a nivel de documento haya salido limpia y al menos una revisión posterior haya cambiado algo más que material de validación o sellos de tiempo; una violación de DocMDP se informa como 3 antes de mirar cualquier campo
  • Los objetos clasificados como relacionados con firmas pero sin nombre de campo, como el diccionario de valor de firma, un diccionario AcroForm reescrito o un cambio de catálogo limitado a /AcroForm o /Perms, hacen que el veredicto devuelva 4 en lugar de adivinar, así que una revisión que añade una firma de aprobación posterior bajo un bloqueo activo suele quedarse en 4
  • Solo los diccionarios de campo y las widget annotations reciben nombre de campo; el mapa de revisiones no tiene clase dedicada para appearance streams, así que un editor que regenera appearances junto con el valor puede ser detenido por la comprobación a nivel de documento como cambio de contenido antes de que corra la comprobación de campos, y la cadena de detalle distingue los dos casos
  • El escaneo se detiene en el primer campo bloqueado o entrada sin nombre que encuentra, así que un 3 nombra un campo infractor, no todos
  • Los bloqueos se agrupan de todos los campos de firma firmados sin ordenarlos en el tiempo, así que un bloqueo escrito por un firmante posterior también se aplica a actualizaciones anteriores a él; esto solo puede empujar un resultado hacia 3, nunca hacia 2
  • Los nombres se comparan de forma exacta y distinguiendo mayúsculas, y el recorrido de /Parent se detiene tras ocho niveles, así que una jerarquía inusualmente profunda se resuelve a un nombre truncado
  • Una entrada FieldMDP cuya estructura no valida y cuya acción no se puede leer hace que el veredicto devuelva 4

Cuando un veredicto vuelve como 4, GetSignatureRevisionIndex da la revisión donde termina la firma y GetDocumentRevisionChanges lista cada objeto que cada revisión posterior añadió, modificó o liberó, con su clase de cambio. Es suficiente para que una persona decida en un minuto. La mecánica de cómo se añaden esas revisiones está cubierta en la guía de actualización incremental de PDFlibPas

FAQ

¿Un veredicto de 2 significa que las firmas son válidas?

No. GetSignatureModificationVerdict examina qué se añadió después de una firma y nunca comprueba el valor de firma CMS, la cadena de certificados ni la revocación. Un 2 dice que las actualizaciones posteriores se mantuvieron dentro de los permisos; la validación criptográfica es un paso aparte y hacen falta ambos

¿Por qué la firma de un segundo aprobador produce 4 en lugar de 2?

Firmar añade un diccionario de valor de firma y normalmente reescribe contabilidad de formularios como el diccionario AcroForm. Esos objetos no pertenecen a ningún campo con nombre, y cuando un bloqueo de campo está activo el veredicto se niega a dar por buena un cambio que no puede atribuir a un campo. Usad GetDocumentRevisionChanges para confirmar que la revisión contiene solo los objetos de firma esperados

¿Puede un solo nombre bloquear todo un grupo de campos?

Sí. Una entrada de bloqueo casa con el campo nombrado y todos los descendientes cuyo nombre completo empieza por él seguido de un punto, así que listar Order en un bloqueo Include congela Order.Customer, Order.Total y cualquier otro campo bajo ese padre

Los veredictos FieldMDP, la firma con bloqueo de campo y la inspección de revisiones vienen todos en la PDFlibPas Delphi PDF Library, junto con las funciones de certificación DocMDP y validación a largo plazo PAdES de las que depende el flujo de aprobación de arriba