Artículo técnico

Verificación de firmas PDF ECDSA en Delphi: DER a P1363

Un signatureValue ECDSA dentro de un contenedor CMS es un SEQUENCE { INTEGER r, INTEGER s } en DER. La función CNG de Windows BCryptVerifySignature no acepta ninguna de esas dos cosas: quiere r || s de ancho fijo en IEEE P1363, sin etiquetas y sin longitudes. HotPDF, el componente PDF VCL nativo para Delphi y C++Builder, convierte entre ambas formas bajo reglas DER estrictas antes de importar una clave

El fallo que esto evita es específico y desmoralizante. Acrobat abre el documento y muestra una marca verde. Tu propio verificador, recorriendo los mismos bytes, devuelve inválido, o CNG devuelve STATUS_INVALID_SIGNATURE sin más explicación. No hay nada mal con la firma. Lo que está mal es que se pasaron unos setenta bytes de ASN.1 a una API que esperaba sesenta y cuatro bytes de entero crudo, y el desajuste es invisible a menos que sepas buscarlo

Por qué BCryptVerifySignature rechaza una firma ECDSA válida

Porque los dos lados de la llamada hablan codificaciones de firma distintas, y ninguno lo anuncia. ISO 32000-1 §12.8 dice que un diccionario de firma lleva un blob CMS en /Contents; RFC 5652 §5.3 dice que el signatureValue en cada SignerInfo es un OCTET STRING cuyo contenido es lo que defina el algoritmo de firma. Para ECDSA ese contenido es la estructura DER de SEC 1: un SEQUENCE que contiene dos INTEGER. Es de longitud variable por diseño, porque r y s son enteros y DER elimina los octetos cero iniciales de los enteros

IEEE P1363 toma la postura opuesta. Define la firma como la concatenación de las dos coordenadas, cada una rellenada por la izquierda con ceros hasta exactamente el ancho en bytes del campo de la curva. Una firma P-256 siempre tiene 64 bytes. Una codificación DER de la misma firma normalmente tiene 70 u 71 bytes y puede variar entre unos 8 y 72. Entregar la forma DER a BCryptVerifySignature hace que solo la comprobación de longitud condene la llamada, que es por qué HotPDF normaliza antes de verificar en lugar de después

uses
  HPDFECDSA;

// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
  out ARaw: TBytes): Boolean;
begin
  Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;

Las reglas DER que un analizador de firmas no debe relajar

Cada rechazo listado aquí es un rechazo que HotPDF realiza de forma deliberada, y cada uno cierra una ruta que un analizador permisivo dejaría abierta. La tentación al escribir un convertidor es encontrar los dos nodos INTEGER, copiar su contenido, y seguir adelante. Eso funciona con una entrada bien formada y acepta en silencio toda una familia de recodificaciones maleables ante una entrada hostil. Por eso HPDFECDSANormalizeSignature rechaza un entero negativo, es decir, cualquier r o s cuyo primer octeto de contenido tenga el bit alto activado, porque un escalar ECDSA válido es positivo. Rechaza un valor que sea enteramente cero, ya que r = 0 o s = 0 nunca es una firma legítima. Rechaza un octeto cero inicial redundante: X.690 §8.3 permite exactamente uno, y solo cuando el siguiente octeto de otro modo se leería como negativo, así que un 00 seguido de un octeto menor a 0x80 es una recodificación, no una firma. Rechaza un encabezado de longitud no mínimo, porque X.690 §10.1 exige la forma definitiva codificada en la menor cantidad de octetos posible, y una longitud en forma larga que podría haber sido forma corta es una cadena de bytes distinta que transporta el mismo significado. Rechaza un entero más ancho que el tamaño de coordenada de la curva, ya que ese valor no puede ser un elemento del campo. Y rechaza cualquier nodo sobrante después de s, junto con un SEQUENCE externo cuya longitud total no sea igual a la longitud de todo el blob

Esos dos últimos importan más de lo que parece. Los bytes sobrantes después del SEQUENCE son el truco clásico de maleabilidad de firmas: anexa basura, y un verificador permisivo sigue diciendo válido mientras la cadena de bytes que validó no es la cadena de bytes que se firmó. El mismo instinto impulsa el endurecimiento de longitudes ASN.1 descrito en la nota sobre el análisis de PKCS#12, y es el mismo instinto aquí. En una ruta de verificación, una estructura aceptada que ningún firmante conforme emitió jamás es un defecto, no una cortesía

El ancho de coordenada pertenece a la curva, no a la firma

HotPDF deriva el ancho de salida a partir del OID de la curva nombrada, nunca de la longitud del DER que acaba de analizar. Esta es la segunda mitad de la conversión y la mitad fácil de equivocar de forma sutil. RFC 5480 §2.1.1 identifica la curva en los parámetros SubjectPublicKeyInfo del certificado, y HPDFECDSACurveFromOID mapea los tres OID que HotPDF admite: 1.2.840.10045.3.1.7 para P-256, 1.3.132.0.34 para P-384, y 1.3.132.0.35 para P-521. HPDFECDSACoordinateSize entonces devuelve 32, 48 o 66 bytes, y el buffer P1363 es el doble de eso: 64, 96 o 132. Cada entero decodificado se alinea a la derecha dentro de su mitad, así que un r corto se rellena con ceros por la izquierda en lugar de desplazarse. P-521 es la que atrapa a la gente, porque 521 bits son 65.125 bytes y se redondean hacia arriba a 66, dando una firma de 132 bytes que ninguna intuición de potencia de dos habría predicho. La clave pública viaja junto a esto como un punto EC sin comprimir según RFC 5480 §2.2, que es 0x04 seguido de X e Y, así que HotPDF comprueba que tenga exactamente 1 + 2 * CoordinateSize bytes y que comience con 0x04 antes de tocar CNG

var
  Digest, SigDER, PublicPoint: TBytes;
  Curve: THPDFECDSACurve;
  Res: THPDFECDSAVerifyResult;
begin
  // secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
  Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');

  // PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
  Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);

  case Res of
    evrValid:
      Memo1.Lines.Add('signature verifies');
    evrInvalid:
      Memo1.Lines.Add('signature does not match the digest');
    evrMalformed:
      Memo1.Lines.Add('DER encoding or public point rejected');
    evrUnsupported:
      Memo1.Lines.Add('curve or algorithm not supported here');
    evrProviderUnavailable:
      Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
    evrProviderError:
      Memo1.Lines.Add('CNG returned an unexpected status');
  end;
end;

Fíjate en el último parámetro. HPDFECDSAVerifyDigest también acepta eseP1363 para quienes ya tienen una firma de ancho fijo, proveniente de un token de hardware o de un servicio de firma remota que devuelve r || s crudo. Esa ruta igual impone la comprobación de longitud y de no-cero en ambas mitades, así que un buffer del tamaño correcto lleno de ceros se rechaza en lugar de pasarse al proveedor

Por qué falla el nombre de algoritmo ECDSA genérico en Windows más antiguo

Porque el nombre genérico es más nuevo que la base de implementación a la que estás distribuyendo. CNG expone un identificador de algoritmo ECDSA que infiere la curva a partir de la clave importada, y es la forma más limpia de escribir este código, pero solo se garantiza que BCryptOpenAlgorithmProvider lo resuelva en versiones más recientes de Windows. En una máquina más antigua la llamada de apertura falla, el handle del proveedor queda nulo, y cada verificación ECDSA en tu aplicación reporta no compatible sobre una firma que es perfectamente válida. HotPDF evita ese precipicio abriendo en su lugar los identificadores por curva. Resuelve ECDSA_P256, ECDSA_P384 y ECDSA_P521 una sola vez, guarda en caché un handle de proveedor por curva, y los cierra en la finalización de la unidad. Cada verificación entonces hace solo el trabajo barato: importar una clave pública temporal desde un ECCPUBLICBLOB, llamar a BCryptVerifySignature, destruir la clave. Sin LoadLibrary repetido, sin GetProcAddress repetido, sin apertura y cierre de proveedor por cada firma. La verificación por lotes de unos cientos de documentos nota la diferencia, y también la nota un proceso de servicio que de otro modo estaría desgastando handles de proveedor bajo carga

Los códigos de resultado se mantienen honestos sobre la distinción. evrProviderUnavailable significa que la máquina no pudo darle a HotPDF un proveedor; evrInvalid significa que CNG respondió STATUS_INVALID_SIGNATURE. Colapsar esos dos en un solo fallo es cómo un problema de implementación termina reportándose como un documento falsificado. La misma separación entre fallo de entorno y fallo criptográfico recorre el manejo de CNG y CAPI en el lado de la firma, cubierto en el artículo sobre firma con el almacén de certificados y el orden de bytes

Qué certificado firmó esto: SignerIdentifier son dos cosas distintas

RFC 5652 §5.3 hace de SignerIdentifier un CHOICE, y un verificador que solo maneje una de las dos ramas verificará en silencio contra la clave equivocada. La primera rama es issuerAndSerialNumber, un SEQUENCE que contiene el Name del emisor en DER crudo y el INTEGER del número de serie, y hacerla coincidir es una comparación de bytes contra cada certificado en el conjunto certificates del CMS. La segunda rama es [0] subjectKeyIdentifier, un OCTET STRING etiquetado implícitamente, y hacerla coincidir requiere excavar dentro del certificado en lugar de comparar sus campos de encabezado

Esa excavación tiene una capa que sorprende a la gente. El identificador de clave vive en una extensión X.509v3, así que HotPDF recorre el campo de extensiones [3] del tbsCertificate, encuentra la extensión cuyo OID es 2.5.29.14, se salta el BOOLEAN opcional de criticidad, y toma el OCTET STRING extnValue. Esa cadena de octetos no es el identificador. Según RFC 5280 §4.2.1.2, su contenido es a su vez DER, y el tipo KeyIdentifier es otro OCTET STRING, así que hay que analizar una segunda vez para llegar a los bytes reales. Deténte una capa antes y compararás un envoltorio de 22 bytes contra un identificador de 20 bytes, ningún certificado coincidirá jamás, y el verificador caerá en cualquier heurística que hayas escrito después, que es el verdadero peligro. Tomar el primer certificado del conjunto es un atajo tentador y está equivocado siempre que el CMS lleva una cadena, que es la mayoría de las veces, porque no se exige que la hoja venga primero. HotPDF solo acepta un certificado sin coincidencia cuando el contenedor tiene exactamente uno; con múltiples certificados presentes, una coincidencia exacta de SignerIdentifier es obligatoria. Verificar un digest contra la clave pública de una CA intermedia no produce un error amigable, produce un inválido confiado sobre un documento que está bien

var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('signed.pdf') > 0 then
      for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
        if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
          Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
            [String(Info.FieldName), String(Info.PublicKeyAlgorithm),
             String(Info.CurveName), String(Info.HashAlgorithm),
             String(Info.SignerName)]))
        else
          Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
  finally
    Pdf.Free;
  end;
end;

THPDFSignatureInfo.CurveName reporta P-256, P-384 o P-521 para que un registro de auditoría anote qué curva se usó realmente en lugar de solo la palabra ECDSA. La lógica a nivel de documento alrededor de esta llamada, en particular cómo se hacen hash los segmentos de /ByteRange y por qué el digest debe calcularse sobre el archivo en lugar de sobre el árbol de objetos analizado, es el tema de el artículo complementario sobre la verificación de firmas digitales PDF

Lo que esto no te da

Un resultado verde de HPDFECDSAVerifyDigest responde una sola pregunta: estos bytes fueron firmados por la clave privada que corresponde a esta clave pública. No dice nada sobre si esa clave pertenece a alguien en quien deberías confiar. La construcción de la cadena hasta un ancla de confianza, la revocación mediante CRL u OCSP, y las comprobaciones de política son trabajo aparte, y cualquier producto que reporte una firma válida sin ellas está reportando menos de lo que el usuario asume. Las fechas de validez del certificado se exponen por separado en THPDFSignatureInfo exactamente por eso: una firma puede verificar criptográficamente mientras el certificado que la creó expiró hace dos años. El soporte de curvas también es deliberadamente limitado. Se manejan tres curvas primas del NIST, y una firma sobre cualquier otra curva devuelve no compatible en lugar de una suposición. La ruta CNG es exclusiva de Windows, lo cual es el trueque correcto para un componente VCL pero vale la pena señalarlo antes de planear un servicio multiplataforma alrededor de esto. Y la severidad no es configurable: no existe un modo permisivo que acepte una longitud DER no mínima porque algún firmante heredado emitió una. Si te encuentras con un archivo así en producción, la respuesta honesta es registrarlo e investigar con el productor, no ensanchar el analizador hasta que el archivo pase

La ruta de verificación ECDSA descrita aquí se incluye como parte del HotPDF Component estándar para Delphi y C++Builder, junto con las rutas RSA PKCS#1 v1.5 y RSA-PSS y el registro completo de información de firma; la página de producto incluye la referencia completa de firmas digitales