Un signatureValue ECDSA dentro de un contenedor CMS es un DER SEQUENCE { INTEGER r, INTEGER s }. La función CNG de Windows BCryptVerifySignature no acepta ninguna de las dos cosas: quiere un r || s de ancho fijo según IEEE P1363, sin etiquetas y sin longitudes. HotPDF, el componente VCL nativo de PDF para Delphi y C++Builder, convierte entre ambos formatos bajo reglas DER estrictas antes de importar una clave
El fallo que esto evita es concreto y desmoralizador. Acrobat abre el documento y muestra un check verde. Tu propio verificador, recorriendo los mismos bytes, devuelve inválido, o CNG responde con STATUS_INVALID_SIGNATURE sin más explicación. No hay nada malo 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 en crudo, y el desajuste es invisible a menos que sepas dónde buscarlo
¿Por qué BCryptVerifySignature rechaza una firma ECDSA válida?
Porque los dos lados de la llamada hablan codificaciones de firma distintas, y ninguno de los dos 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 de cada SignerInfo es un OCTET STRING cuyo contenido es lo que sea 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 adopta la postura contraria. 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 oscilar entre unos 8 y 72. Entregar la forma DER a BCryptVerifySignature y la sola comprobación de longitud arruina la llamada, razón por la cual 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 parser de firmas no debe relajar
Cada rechazo listado aquí es un rechazo que HotPDF realiza deliberadamente, y cada uno cierra una vía que un parser permisivo dejaría abierta. La tentación al escribir un conversor es encontrar los dos nodos INTEGER, copiar su contenido, y seguir adelante. Eso funciona con entrada bien formada y acepta silenciosamente toda una familia de recodificaciones maleables con 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 se leería como negativo en caso contrario, así que un 00 seguido de un octeto por debajo de 0x80 es una recodificación, no una firma. Rechaza una cabecera de longitud no mínima, porque X.690 §10.1 exige la forma definida codificada en el menor número de octetos posible, y una longitud de forma larga que podría haberse expresado en 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 cuerpo. Y rechaza cualquier nodo adicional tras s, junto con un SEQUENCE exterior cuya longitud total no coincida con la longitud de todo el blob
Esos dos últimos puntos importan más de lo que parece. Los bytes sobrantes tras el SEQUENCE son el truco clásico de maleabilidad de firma: añade basura, y un verificador indulgente 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 longitud ASN.1 descrito en la nota sobre el parsing 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 del OID de la curva con nombre, nunca de la longitud del DER que acaba de analizar. Esta es la segunda mitad de la conversión y la mitad que es fácil de estropear sutilmente. RFC 5480 §2.1.1 identifica la curva en los parámetros de SubjectPublicKeyInfo del certificado, y HPDFECDSACurveFromOID mapea los tres OID que soporta HotPDF: 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 devuelve entonces 32, 48 o 66 bytes, y el búfer P1363 es el doble: 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 suele atrapar 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 ella 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 mide exactamente 1 + 2 * CoordinateSize bytes y que empieza por 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 llamantes que ya tienen una firma de ancho fijo, procedente de un token hardware o un servicio remoto de firma que devuelve r || s en crudo. Esa ruta sigue aplicando la comprobación de longitud y de no-cero en ambas mitades, así que un búfer 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 antiguo?
Porque el nombre genérico es más reciente que la base de despliegue en la que estás publicando. CNG expone un identificador de algoritmo ECDSA que infiere la curva a partir de la clave importada, y es la forma limpia de escribir este código, pero BCryptOpenAlgorithmProvider solo tiene garantizada su resolución en versiones más recientes de Windows. En una máquina más antigua la apertura falla, el handle de proveedor se queda en nil, y cada verificación ECDSA de tu aplicación reporta no soportado 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, almacena 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 abrir y cerrar proveedor por 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 creando y destruyendo handles de proveedor bajo carga
Los códigos de resultado se mantienen honestos respecto a esa distinción. evrProviderUnavailable significa que la máquina no pudo darle a HotPDF un proveedor; evrInvalid significa que CNG respondió STATUS_INVALID_SIGNATURE. Colapsar ambos en un solo fallo es cómo un problema de despliegue acaba reportado como un documento falsificado. La misma separación entre fallo de entorno y fallo criptográfico recorre el manejo de CNG y CAPI del lado de la firma, tratado en el artículo sobre firma con el almacén de certificados y orden de bytes
¿Quién firmó esto? SignerIdentifier son dos cosas distintas
RFC 5652 §5.3 convierte SignerIdentifier en un CHOICE, y un verificador que solo maneje una de las ramas verificará silenciosamente contra la clave equivocada. La primera rama es issuerAndSerialNumber, un SEQUENCE que contiene el Name del emisor en DER crudo y el INTEGER de número de serie, y compararla es una comparación de bytes contra cada certificado del conjunto certificates del CMS. La segunda rama es [0] subjectKeyIdentifier, un OCTET STRING con etiquetado implícito, y compararla exige escarbar en el certificado en lugar de comparar sus campos de cabecera
Ese escarbe 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 [3] extensions del tbsCertificate, encuentra la extensión cuyo OID es 2.5.29.14, se salta el BOOLEAN opcional critical, 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. Detente una capa antes y compararás un envoltorio de 22 bytes contra un identificador de 20 bytes, ningún certificado coincidirá nunca, y el verificador caerá de vuelta en cualquier heurística que hayas escrito a continuación, que es el verdadero peligro. Tomar el primer certificado del conjunto es un atajo tentador y es incorrecto siempre que el CMS lleve una cadena, que es la mayoría de las veces, porque no es obligatorio que el certificado hoja venga primero. HotPDF solo acepta un certificado sin correspondencia cuando el contenedor tiene exactamente uno; con varios 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 amistoso, produce un inválido confiado sobre un documento que está perfectamente 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 informa P-256, P-384 o P-521 para que un registro de auditoría deje constancia de qué curva se usó realmente en lugar de solo la palabra ECDSA. La instalación a nivel de documento en torno a esta llamada, en particular cómo se codifican en hash los segmentos de /ByteRange y por qué el digest debe calcularse sobre el fichero y no sobre el árbol de objetos analizado, es el tema del artículo complementario sobre verificación de firmas digitales en PDF
Lo que esto no te da
Un resultado verde de HPDFECDSAVerifyDigest responde a una única 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 informe de una firma válida sin ellas está informando de menos de lo que el usuario asume. Las fechas de validez del certificado se exponen por separado en THPDFSignatureInfo exactamente por esa razón: una firma puede verificar criptográficamente mientras el certificado que la generó caducó hace dos años. El soporte de curvas también es deliberadamente reducido. Se manejan tres curvas primas NIST, y una firma sobre cualquier otra curva devuelve no soportado en lugar de una suposición. La ruta CNG es exclusiva de Windows, lo cual es la decisión correcta para un componente VCL pero merece mencionarse antes de planificar un servicio multiplataforma en torno a ella. Y la severidad no es configurable: no existe un modo indulgente que acepte una longitud DER no mínima porque algún firmante heredado emitiera una. Si te encuentras con un fichero así en producción, la respuesta honesta es registrarlo y perseguir al productor, no ampliar el parser hasta que el fichero 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 del producto incluye la referencia completa de firma digital