HotPDF performs elliptic-curve key agreement and signature verification for PDF in pure Object Pascal, with no OpenSSL binding and no platform crypto provider in the path. That covers five curves: P-256, P-384 and P-521 for the NIST prime families, plus X25519 and X448 for Montgomery-curve key agreement. The reason to write that code rather than link it is deployment, not purity. A Delphi or Free Pascal application that ships one executable and no cryptographic DLL has no version skew to manage, no per-platform provider to detect, and nothing that changes behavior when a customer patches their system libraries
The cost is that you now own the arithmetic. Big-integer modular multiplication is unforgiving code: it either produces byte-identical results against published test vectors or it produces plausible-looking garbage, and the distance between those two states can be a single comparison. This is the story of that comparison, because the shape of the bug generalises to any Pascal port of field arithmetic
Why does a PDF library need curve arithmetic at all?
Two features pull it in. The first is public-key encryption of documents: the ISO 32000 recipient-list handler wraps a per-document key for named certificates, and when a recipient holds an EC key the wrapping runs through key-agreement rather than RSA key transport. Without ECDH there is no way to open such a document. The second is signature validation. Verifying an ECDSA signature over the /ByteRange bytes needs a point multiplication on the signer curve, and P-384 is common in government and qualified-signature profiles where P-256 is considered the floor rather than the target. HotPDF exposes the results of that work through the ECDSA and CMS verification path and through the pluggable signature-provider model
CIOS, and the one subtraction at the end
Montgomery multiplication avoids division by working in a transformed domain where reduction is a shift. The variant HotPDF uses is Coarsely Integrated Operand Scanning, which interleaves the multiply and the reduction limb by limb so the intermediate never grows past the modulus width plus one limb. The loop body is straightforward and easy to test. The tail is not: after the interleaved passes the accumulator can be anywhere in the range up to twice the modulus, so the algorithm ends with a conditional subtraction that removes one copy of the prime if and only if the accumulator is greater than or equal to it
Comparing two multi-limb numbers means walking from the most significant limb downwards while carrying a borrow. The obvious way to write it is to compare the accumulator limb against the modulus limb plus the incoming borrow. That expression is wrong, and it is wrong in a way that most curves hide
// Wrong: P[I] + Borrow can wrap when P[I] is $FFFFFFFFFFFFFFFF
if T[I] < P[I] + Borrow then
begin
Borrow := 1;
Break;
end;
// Correct: compare without ever adding to a limb
if (T[I] < P[I]) or ((T[I] = P[I]) and (Borrow = 1)) then
begin
Borrow := 1;
Break;
end;
What does a borrow wrap-around actually look like?
It looks like a curve that works everywhere except in production. The primes for P-384 and P-521 contain limbs that are entirely ones, so P[I] equals $FFFFFFFFFFFFFFFF. Add the incoming borrow of one to that and a 64-bit unsigned wraps to zero. The comparison then asks whether the accumulator limb is less than zero, decides it is not, and concludes that no borrow is needed. One limb of the result is off by one
P-256 escapes because none of its limbs are all-ones, so the addition never overflows and the buggy expression happens to agree with the correct one. That is the worst possible outcome for a test suite: the most-tested curve passes, the less-tested ones fail intermittently depending on operand values, and the failure surfaces as a verification result of "invalid signature" on documents that are perfectly valid. HotPDF carried an explicit gate on P-384 for exactly this reason, returning an unavailable status rather than a wrong answer, until the arithmetic was proven against reference vectors
How the bug was actually located
Not by reading the code. The productive sequence was mechanical, and it is reusable. First, eliminate the constants: every limb of p, R and R^2 was regenerated independently and compared limb by limb, which rules out the single most common source of curve bugs. Second, instrument the arithmetic rather than the API: a temporary dump procedure printed the intermediate values of the Montgomery multiplication of R^2, of x^3, and of y^2 for a known point, so they could be checked against independently computed truth
That comparison pointed straight at the culprit. The x chain was correct end to end, while y^2 differed in exactly one limb by exactly one. A single-limb difference of one is not a multiplication bug, a carry-propagation bug, or a constant bug; it is a borrow-chain bug, and the only borrow chain in the routine is the final conditional subtraction. One detail nearly derailed this: the reference constant used for the dump was itself written in the wrong byte order on the first attempt, which produced a mismatch in the y value and briefly suggested a second, non-existent defect. Verify the endianness of your ground truth before you trust it to accuse your code
The neighbouring traps in the same routine
Three more failure modes live within a few lines of that comparison, and all three were live at some point during development
// 1. The accumulator has one limb above the modulus width. Comparing only
// the low L limbs misses the case where T equals exactly p plus 2^(64*L),
// which happens for a meaningful share of random inputs because 2p
// exceeds 2^256 for P-256 and 2^384 for P-384
if (T[L] <> 0) or NotLessThanModulus(T, P, L) then
SubtractModulus(T, P, L);
// 2. A generic multi-limb subtract has the same wrap hazard: when
// Y[I] is $FFFFFFFFFFFFFFFF, Y[I] + Borrow wraps to zero and the
// borrow must survive into the next limb rather than being cleared
Diff := X[I] - Y[I] - Borrow;
NextBorrow := Ord((X[I] < Y[I]) or ((X[I] = Y[I]) and (Borrow = 1)));
The third is not code, it is provenance. The prime for P-521 was initially transcribed with 130 hexadecimal digits instead of 131, one F short, and the Montgomery constants were then computed from that wrong prime, so the constants were self-consistent and jointly wrong. Curve parameters must be derived, never typed: compute R as (1 shl (64 * L)) mod p from the prime you are actually using, then cross-check R * R mod p against the value your R^2 constant claims. A pair of constants that agree with each other proves nothing about either one
Verification strategy that scales beyond one curve
The technique that made X25519 and X448 tractable was writing a mirror implementation in a language with unbounded integers and transcribing the Pascal control flow into it line by line. When the mirror produces the right answer and the Pascal does not, the defect is a transcription slip and probing the same intermediate value in both implementations finds it in seconds. All three classic RFC 7748 ladder mistakes were caught this way: a constant-time swap whose second line reused the already-swapped value, a final inversion that returned z to the power minus one instead of multiplying it into X, and a small-constant multiplication that assembled half-word products with a bitwise or and lost the carry
For test material, take vectors as bytes rather than as text. Extracting a private key with a text pattern is how a correct implementation gets accused of an off-by-one-byte error that lives entirely in the extraction step. Slice the hex out of the DER encoding at known offsets and compare byte arrays
With the borrow chain corrected, all five curves match published reference vectors byte for byte, and HotPDF no longer gates any of them. If you are integrating certificate-based signing or recipient-list encryption, the practical takeaway is that curve choice is now a policy decision rather than a capability question; the profiles and byte-order pitfalls of the signing side are covered in the PAdES signing walkthrough. Component details and the supported algorithm matrix are on the HotPDF Delphi PDF component product page