PDFlibPas signs and verifies with Ed448 and with the three Brainpool ECDSA curves in pure Object Pascal. No external cryptographic library, no platform provider, no DLL: PDFlibEd448 implements RFC 8032 PureEdDSA on edwards448, and PDFlibBrainpool implements RFC 5639 brainpoolP256r1, brainpoolP384r1 and brainpoolP512r1. Both were built the same way, against known-answer vectors generated independently before any Pascal was written, and both are worth writing about mainly for the bugs
Field arithmetic is unusually honest code. It either matches published vectors byte for byte or it does not, so there is no space for "mostly working". What makes it difficult is that a wrong implementation still produces signatures, still verifies its own signatures, and still looks completely plausible
Why these curves, and why in Pascal
Brainpool curves appear in European qualified-signature profiles, so a library that signs documents for that market cannot treat them as exotic. Ed448 is in the algorithm set that ISO/TS 32002 brings to PDF, where its internal digest is SHAKE256 rather than SHA-2. Neither family is available in the Pascal cryptographic libraries in common use, so a PDF library that wants them has to own them
The deployment argument is the same one that applies to all of this library's cryptography: an application that ships one binary with no cryptographic dependency has no provider to detect, no version to match and no behaviour that changes when the host is patched. Signing is exactly the area where you least want a moving dependency
Constants come from the specification text, never from memory
The first attempt at the edwards448 base point was written from memory and was wrong. That is not a remarkable mistake and it is a very expensive one, because a wrong base point produces a self-consistent system: your key generation, signing and verification all agree with each other and disagree with the rest of the world
The working procedure is to take every domain parameter from the specification text, then cross-verify. For edwards448 that means the prime, the curve constant, the group order and both decimal coordinates of the base point out of RFC 8032, converted into the internal limb representation, and then checked against published test vectors from the same document. For the Brainpool curves it means the parameters from RFC 5639, an independent implementation written to generate vectors, and a cross-check against a system library in both directions before any Pascal ran
One derivation shortcut deserves a warning because it looks universal and is not: recovering the base point from a fixed y value works for the 25519 curve and does not work for edwards448, where that value has no square root. A script disproved it in seconds, which is much cheaper than discovering it through a debugger
The method: a limb-level mirror before any Pascal
The technique that made both units tractable is a mirror implementation in a language with unbounded integers, built bottom-up. First the arithmetic layer alone: field multiply, subtract and carry propagation, stress-tested against their algebraic invariants over a couple of hundred random cases. Then the full key generation inside the mirror, which is where the semantic bugs live and where they are cheap to find. Only then the Pascal transcription
The payoff is diagnostic rather than developmental. Once the mirror is known correct, any disagreement between mirror and Pascal is a transcription slip, and probing the same intermediate value in both implementations locates it immediately. That converts a class of bug that is otherwise nearly undebuggable, a single wrong limb deep inside a scalar multiplication, into a five-minute comparison
Four root causes in Ed448
All four were found by probing intermediate values, and all four are the kind of thing that produces valid-looking output
The first is a notation trap. Most published formulae for unified Edwards addition assume a curve constant of minus one, and edwards448 has plus one. Carried over unchanged, the numerator of the y coordinate is written as a sum where it should be a difference. The fix is not to patch the sign but to re-derive the inversion-free product form from the affine addition law for the correct curve, which produces the four coordinate expressions and leaves no room for a sign to be inherited from the wrong source
The second is in point decompression. Recovering the affine x from projective coordinates requires one multiplication by the inverse of Z. Multiplying by the inverse squared yields a value that is still a valid projective representation and is the wrong affine coordinate, so the symptom is a correct y with a wrong x. Any time one coordinate is right and the other is not, the bug is in the normalisation, not the arithmetic
The third is a habit imported from the shorter curve. Both the per-signature scalar and the challenge scalar must be reduced from the complete digest, which for Ed448 is 114 bytes, not from its first 57. The 32-byte curve uses its full 64-byte digest as well, so the rule is consistent; it is only the assumption that "half the digest is the scalar width" that is wrong
The fourth is ordering. The domain separation prefix comes first, before the context prefix and the message, which is not the order the intuitive reading of R and A in the specification suggests. Getting this wrong produces signatures that verify against your own implementation and nothing else, which is the most misleading possible failure
// Field carry design: pure floor-semantics propagation, so both
// positive and negative limbs work and subtraction needs no bias.
// The top carry folds back through 2^448 = 2^224 + 1 (mod p), which
// touches limb 0 and limb 8. Bounded at four rounds; two observed
// in practice
procedure FeCarry(var A: TFe448);
var
I, Round: Integer;
Carry: Int64;
begin
for Round := 1 to 4 do
begin
Carry := 0;
for I := 0 to 15 do
begin
A[I] := A[I] + Carry;
Carry := Floor28(A[I]); // floor, not truncation
A[I] := A[I] - (Carry shl 28);
end;
if Carry = 0 then
Break;
A[0] := A[0] + Carry; // 2^448 == 1
A[8] := A[8] + Carry; // 2^448 == 2^224
end;
end;
An earlier version of that routine applied a bias before propagating, and under large inputs it folded a spurious carry of the wrong magnitude into the low limbs. Bias-based carry schemes are a persistent source of this class of defect; floor semantics with a bounded repeat loop is easier to reason about and measurably fast enough
Two root causes in Brainpool
The first is not cryptography at all. The working representation is 33 limbs, so the product of two values needs 66, and the product array was declared with 64. Writing past the end corrupted adjacent memory, which presented first as wrong results and only became a crash once a wider scan was added. The rule that came out of it is worth applying to every fixed-size numeric buffer: size it from the worst-case product width and add margin, then never think about it again. The array in the shipping code is 68 limbs
The second is a mixed-up exponentiation shape. There are two correct square-and-multiply forms and they consume the exponent in opposite directions: the right-to-left form multiplies then squares the base and must read bits from the least significant end, while the left-to-right form squares then multiplies and reads from the most significant end. The modular inversion loop had a right-to-left body with a most-significant-first bit walk. Both halves are textbook, the combination is not, and the result is a wrong inverse that still looks like a plausible field element
// Jacobian doubling and addition where the destination record may be
// the same variable as a source. A whole-record copy at entry is the
// only reliable defence: writing R's limbs pollutes later reads of P
procedure BPPointDouble(var R: TBPPoint; const P: TBPPoint;
const Curve: TBPCurve);
var
Pin: TBPPoint;
begin
Pin := P; // copy first, then compute from Pin only
// ... M = 3X^2 + A*Z^4, S = 4*X*Y^2, X3 = M^2 - 2S, ...
end;
Two process lessons that cost more than the bugs
Incremental hot-fixing does not converge on a cryptographic unit. One draft was patched repeatedly until it carried 32 duplicated routines and a damaged structure, and it was only fixed by rewriting it. The pattern to adopt is either write it once from a validated mirror or rewrite it; a sequence of local fixes to arithmetic you do not yet understand accumulates faster than it corrects
And check the timestamp on the executable before you believe a test result. An incremental build that compiles but does not relink runs the previous binary, which manufactured an entire round of false leads about missing probes and duplicated output. When debugging cryptography, an unexplained result should prompt "is this the binary I just built" before "is the algorithm wrong"
Performance, scope and how to call it
Modular reduction in the Brainpool unit is bit-serial shift-subtract from the highest set bit of the product, so a multiplication costs roughly on the order of the bit width. A P-256 verification lands in the low hundreds of milliseconds, which is unremarkable for signing or verifying documents and would be inadequate for a TLS terminator. Barrett reduction is the obvious upgrade and needs a wider working value than the current representation carries, so it is a change to make when a workload asks for it rather than pre-emptively
uses
PDFlibEd448, PDFlibBrainpool;
var
PublicKey, Signature: AnsiString;
Curve: TBPCurve;
R, S, PubX, PubY: TBPValue;
begin
// Ed448: PureEdDSA, SHAKE256 internally, 57-byte keys
if Ed448PublicKeyFromSeed(Seed, PublicKey) and
Ed448Sign(DocumentDigest, Seed, Signature) then
Assert(Ed448Verify(DocumentDigest, PublicKey, Signature));
// Brainpool: caller supplies the per-signature nonce, so nonce
// policy stays with the application
Curve := BPLoadCurve(bpP256r1);
if BPKeyGen(PubX, PubY, PrivateD, Curve) and
BPSignFixedK(R, S, Hash, PrivateD, Nonce, Curve) then
Assert(BPVerify(R, S, Hash, PubX, PubY, Curve));
end;
Note that the Brainpool signing entry point takes the nonce rather than generating one. That is deliberate: nonce generation is the single most catastrophic thing to get wrong in ECDSA, since a repeated or predictable value discloses the private key, and the decision about where randomness comes from belongs to the application and its compliance regime, not to a PDF library
These curves sit alongside the post-quantum work described in the FIPS 204 ML-DSA article, and they plug into the same signing and validation pipeline covered in PAdES signing and validation. For test certificates on these curves, the local generation route is described in self-signed certificates with CryptoAPI. The full algorithm matrix is listed on the losLab PDF Developer Library product page