Technical Article

ECDSA PDF Signature Verification in Delphi: DER to P1363

An ECDSA signatureValue inside a CMS container is a DER SEQUENCE { INTEGER r, INTEGER s }. The Windows CNG function BCryptVerifySignature accepts neither of those: it wants IEEE P1363 fixed-width r || s, with no tags and no lengths. HotPDF, the native VCL PDF component for Delphi and C++Builder, converts between the two under strict DER rules before importing a key

The failure this prevents is a specific and demoralising one. Acrobat opens the document and shows a green check. Your own verifier, walking the same bytes, returns invalid, or CNG hands back STATUS_INVALID_SIGNATURE with no further explanation. Nothing is wrong with the signature. What is wrong is that roughly seventy bytes of ASN.1 were passed to an API that expected sixty-four bytes of raw integer, and the mismatch is invisible unless you know to look for it

Why does BCryptVerifySignature reject a valid ECDSA signature?

Because the two sides of the call speak different signature encodings, and neither one announces it. ISO 32000-1 §12.8 says a signature dictionary carries a CMS blob in /Contents; RFC 5652 §5.3 says the signatureValue in each SignerInfo is an OCTET STRING whose content is whatever the signature algorithm defines. For ECDSA that content is the SEC 1 DER structure: a SEQUENCE holding two INTEGERs. It is variable length by design, because r and s are integers and DER strips leading zero octets from integers

IEEE P1363 takes the opposite view. It defines the signature as the concatenation of the two coordinates, each left-padded with zeros to exactly the byte width of the curve field. A P-256 signature is always 64 bytes. A DER encoding of the same signature is normally 70 or 71 bytes and can be anywhere from about 8 to 72. Hand the DER form to BCryptVerifySignature and the length check alone dooms the call, which is why HotPDF normalises before it verifies rather than after

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;

The DER rules a signature parser must not relax

Every rejection listed here is a rejection HotPDF performs deliberately, and each one closes a path that a forgiving parser would leave open. The temptation when writing a converter is to find the two INTEGER nodes, copy their content, and move on. That works on well-formed input and quietly accepts a family of malleable re-encodings on hostile input. So HPDFECDSANormalizeSignature rejects a negative integer, meaning any r or s whose first content octet has the high bit set, because a valid ECDSA scalar is positive. It rejects a value that is entirely zero, since r = 0 or s = 0 is never a legitimate signature. It rejects a redundant leading zero octet: X.690 §8.3 permits exactly one, and only when the next octet would otherwise read as negative, so a 00 followed by an octet under 0x80 is a re-encoding, not a signature. It rejects a non-minimal length header, because X.690 §10.1 requires the definite form encoded in the fewest octets, and a long-form length that could have been short-form is a different byte string carrying the same meaning. It rejects an integer wider than the curve coordinate size, since that value cannot be a field element. And it rejects any trailing node after s, along with an outer SEQUENCE whose total length does not equal the length of the whole blob

Those last two matter more than they look. Trailing bytes after the SEQUENCE are the classic signature-malleability trick: append junk, and a lenient verifier still says valid while the byte string it validated is not the byte string that was signed. The same instinct drives the ASN.1 length hardening described in the note on PKCS#12 parsing, and it is the same instinct here. In a verification path, an accepted structure that was never emitted by a conforming signer is a defect, not a courtesy

Coordinate width belongs to the curve, not to the signature

HotPDF derives the output width from the named curve OID, never from the length of the DER it just parsed. This is the second half of the conversion and the half that is easy to get subtly wrong. RFC 5480 §2.1.1 identifies the curve in the certificate SubjectPublicKeyInfo parameters, and HPDFECDSACurveFromOID maps the three OIDs HotPDF supports: 1.2.840.10045.3.1.7 for P-256, 1.3.132.0.34 for P-384, and 1.3.132.0.35 for P-521. HPDFECDSACoordinateSize then returns 32, 48, or 66 bytes, and the P1363 buffer is twice that: 64, 96, or 132. Each decoded integer is right-aligned into its half, so a short r is zero-padded on the left rather than shifted. P-521 is the one that catches people, because 521 bits is 65.125 bytes and rounds up to 66, giving a 132-byte signature that no power-of-two intuition would have predicted. The public key travels alongside as an uncompressed EC point per RFC 5480 §2.2, which is 0x04 followed by X and Y, so HotPDF checks it is exactly 1 + 2 * CoordinateSize bytes and begins with 0x04 before touching 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;

Note the last parameter. HPDFECDSAVerifyDigest also accepts eseP1363 for callers that already hold a fixed-width signature, from a hardware token or a remote signing service that returns raw r || s. That path still enforces the length and the non-zero check on both halves, so a buffer of the right size full of zeros is refused rather than passed through to the provider

Why does the generic ECDSA algorithm name fail on older Windows?

Because the generic name is newer than the deployment base you are shipping into. CNG exposes an algorithm identifier ECDSA that infers the curve from the imported key, and it is the clean way to write this code, but BCryptOpenAlgorithmProvider is only guaranteed to resolve it on more recent Windows versions. On an older machine the open call fails, the provider handle stays nil, and every ECDSA verification in your application reports unsupported on a signature that is perfectly good. HotPDF avoids the cliff by opening the per-curve identifiers instead. It resolves ECDSA_P256, ECDSA_P384, and ECDSA_P521 once, caches one provider handle per curve, and closes them in unit finalization. Each verification then does only the cheap work: import a temporary public key from an ECCPUBLICBLOB, call BCryptVerifySignature, destroy the key. No repeated LoadLibrary, no repeated GetProcAddress, no provider open and close per signature. Batch verification of a few hundred documents feels the difference, and so does a service process that would otherwise churn provider handles under load

The result codes stay honest about the distinction. evrProviderUnavailable means the machine could not give HotPDF a provider; evrInvalid means CNG answered STATUS_INVALID_SIGNATURE. Collapsing those two into one failure is how a deployment problem gets misreported as a forged document. The same separation between environment failure and cryptographic failure runs through the CNG and CAPI handling on the signing side, covered in the article on certificate store signing and byte order

Which certificate signed this? SignerIdentifier is two different things

RFC 5652 §5.3 makes SignerIdentifier a CHOICE, and a verifier that handles only one arm will silently verify against the wrong key. The first arm is issuerAndSerialNumber, a SEQUENCE holding the issuer Name in raw DER and the serial INTEGER, and matching it is a byte comparison against each certificate in the CMS certificates set. The second arm is [0] subjectKeyIdentifier, an implicitly tagged OCTET STRING, and matching it requires digging into the certificate rather than comparing its header fields

The dig has a layer that surprises people. The key identifier lives in an X.509v3 extension, so HotPDF walks the [3] extensions field of the tbsCertificate, finds the extension whose OID is 2.5.29.14, skips the optional critical BOOLEAN, and takes the extnValue OCTET STRING. That octet string is not the identifier. Per RFC 5280 §4.2.1.2 its content is itself DER, and the KeyIdentifier type is another OCTET STRING, so you parse a second time to reach the actual bytes. Stop one layer early and you compare a 22-byte wrapper against a 20-byte identifier, no certificate ever matches, and the verifier falls back on whatever heuristic you wrote next, which is the real hazard. Taking the first certificate in the set is a tempting shortcut and it is wrong whenever the CMS carries a chain, which is most of the time, because the leaf is not required to come first. HotPDF only accepts an unmatched certificate when the container holds exactly one; with multiple certificates present, an exact SignerIdentifier match is mandatory. Verifying a digest against an intermediate CA public key does not produce a friendly error, it produces a confident invalid on a document that is fine

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 reports P-256, P-384, or P-521 so an audit log records which curve was actually used rather than just the word ECDSA. The document-level plumbing around this call, in particular how the /ByteRange segments are hashed and why the digest must be computed over the file rather than the parsed object tree, is the subject of the companion article on verifying PDF signatures

What this does not give you

A green result from HPDFECDSAVerifyDigest answers one question only: these bytes were signed by the private key matching this public key. It says nothing about whether that key belongs to anyone you should trust. Chain building to a trust anchor, revocation through CRL or OCSP, and policy checks are separate work, and any product that reports a valid signature without them is reporting less than the user assumes. Certificate validity dates are surfaced separately in THPDFSignatureInfo for exactly that reason: a signature can verify cryptographically while the certificate that made it expired two years ago. The curve support is also deliberately narrow. Three NIST prime curves are handled, and a signature over any other curve returns unsupported rather than a guess. The CNG path is Windows-only, which is the right trade for a VCL component but worth stating before you plan a cross-platform service around it. And the strictness is not configurable: there is no lenient mode that accepts a non-minimal DER length because some legacy signer emitted one. If you meet such a file in production, the honest response is to record it and chase the producer, not to widen the parser until the file passes

The ECDSA verification path described here ships as part of the standard HotPDF Component for Delphi and C++Builder, alongside the RSA PKCS#1 v1.5 and RSA-PSS paths and the full signature information record; the product page carries the complete digital signature reference