Two JSON encoders looked at the same customer name and signed different bytes
We had a manual step we wanted gone: a customer buys a license, someone runs a Python script that signs a JSON file with an Ed25519 key, someone uploads the result. Fine at low volume, annoying forever. The fix looked trivial — the signing algorithm is maybe fifteen lines — so we ported it into the Cloudflare Worker that already handles the purchase webhook, in JavaScript, so the whole thing runs the moment a payment completes.
Fifteen lines of crypto, ported across two languages, found two real bugs before it shipped. Neither was in the signing math.
bug one: WebCrypto only wants to see half your key
The signing algorithm itself is simple: take a JSON payload, canonicalize it, sign the bytes with Ed25519, done. The private key is 32 raw bytes, base64-encoded, sitting in a file. Loading 32 raw bytes into a crypto API should be the easy part.
await crypto.subtle.importKey("raw", privateKeyBytes, { name: "Ed25519" }, false, ["sign"]);
→ SyntaxError: Unsupported key usage for a Ed25519 key
Turns out "raw" import for Ed25519 is defined, per the Secure Curves spec,
for public keys only. A private key has to arrive wrapped in PKCS8 DER — a
fixed 16-byte ASN.1 header in front of the exact same 32 bytes. Not obvious from the error
message, which just says the usage is unsupported and lets you guess why.
// RFC 8410's Ed25519 PKCS8 encoding: a fixed prefix, then the raw 32-byte seed.
const PKCS8_PREFIX = new Uint8Array([
0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x70,0x04,0x22,0x04,0x20,
]);
const pkcs8 = concat(PKCS8_PREFIX, rawPrivateKeyBytes);
const key = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
Sixteen magic bytes, well-known enough that half the WebCrypto ecosystem has independently reinvented the same workaround. Once you know it exists it's a non-issue; the first time, it's an hour of reading the wrong error message.
bug two: two encoders, one customer, different opinions
The actual signed message is a canonical JSON encoding of the license fields —
sorted keys, no whitespace. Python builds it with json.dumps(payload, sort_keys=True,
separators=(",", ":")). The obvious JavaScript equivalent is
JSON.stringify with the keys pre-sorted. They look equivalent. They aren't.
json.dumps defaults to ensure_ascii=True: every character
outside plain ASCII gets escaped to a \uXXXX sequence. JSON.stringify
does no such thing — it emits raw UTF-8. Feed both encoders a customer name like
"Café Örg" and you get two different byte sequences for what is, semantically,
the exact same JSON object:
json.dumps({"customer": "Café"}) → {"customer": "Café"}
JSON.stringify({"customer": "Café"}) → {"customer":"Café"}
The signature covers whichever bytes actually got signed. The verifier re-derives its own canonical message and checks the signature against that. If the two sides disagree on what "canonical" means for a single non-ASCII character, every license for a customer whose name isn't pure ASCII fails verification — silently, at the customer's install, with no signal back to us that anything was ever wrong on our end. That's a nastier failure mode than a crash: a rare, hard-to-reproduce, customer-specific rejection that looks like it must be their problem.
The fix is a JSON string-escaper that replicates Python's default behavior on purpose:
walk the string, escape anything outside printable ASCII as \uXXXX, leave
everything else alone. Un-clever by design — the entire point is matching a spec Python
already picked, not picking a better one.
how we actually know it's fixed
Neither bug would have been caught by testing the JavaScript signer against itself. A same-language round trip only proves the code agrees with itself, not with the thing that actually verifies a real customer's license. So the test suite doesn't verify JS against JS — it signs a license with the new code, using a disposable test keypair, and hands the result to the real, unmodified Python verifier already shipping to customers. A customer name with accented Latin characters and CJK characters in the same string, run through both sides, has to come out verified on the other end or the test fails.
It also deliberately tries to fail: a tampered field, a signature checked against the wrong public key, an already-expired license. Each has to be rejected by the real verifier, proving the signature actually covers the payload rather than being checked in a way that would pass regardless.
the bug that wasn't even about crypto
Writing a more realistic test for this surfaced a third, unrelated bug: the webhook that figures out which product a completed payment was for had been comparing the wrong two values the entire time — a stored key against a lookup table indexed by a different, similarly- named key. It could never have matched. The existing tests didn't catch it because they'd been written with the same wrong assumption baked into their fake input; realistic input, from the new tests, was what broke it open. Nothing about that bug involved Ed25519, JSON, or any of the code discussed above — it was just sitting there once someone actually looked with a fresh test.
This is the delivery pipeline behind one of our actual products — reach out via northstartproductionstudio.com if you want to see the real thing, not just the postmortem.