Skip to content

Commit f98d080

Browse files
sgf36claude
andcommitted
Worker: sign via node:crypto (nodejs_compat) instead of WebCrypto
Cloudflare docs confirm full node:crypto support under the nodejs_compat flag, and it accepts the PEM key directly. This avoids the Workers runtime's historical WebCrypto Ed25519 algorithm-naming difference (NODE-ED25519 vs Ed25519), and is the exact code path we can test locally under Node. Verified against the shipped module: mints keys the app accepts, and rejects tampered bodies and stale timestamps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 47aa67f commit f98d080

3 files changed

Lines changed: 39 additions & 48 deletions

File tree

server/paddle-license-webhook-worker/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ into an emailed license key. On `transaction.completed` for the license price,
55
it verifies the signed webhook, mints an Ed25519-signed key (the format
66
`app/core/license.py` verifies offline), and emails it via Resend.
77

8-
Ed25519 signing and HMAC verification use the Workers WebCrypto runtime — no
9-
native dependencies. Stateless; all secrets are Worker bindings.
8+
Ed25519 signing and HMAC verification use `node:crypto` (hence the
9+
`nodejs_compat` compatibility flag in `wrangler.toml`). That API is fully
10+
supported in Workers, takes the PEM key directly, and is the same code path we
11+
verify locally under Node — avoiding the Workers runtime's historical
12+
WebCrypto Ed25519 algorithm-naming differences. Stateless; all secrets are
13+
Worker bindings.
1014

1115
> This is the recommended deployment. A container/FastAPI equivalent lives in
1216
> `../paddle-license-webhook/` if you ever want to self-host instead.

server/paddle-license-webhook-worker/src/worker.js

Lines changed: 31 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
* webhook, mints an Ed25519-signed offline license key (the format
66
* app/core/license.py verifies), and emails it to the buyer via Resend.
77
*
8-
* Always-warm, no cold starts. All secrets come from Worker bindings; nothing
9-
* sensitive is in the repo.
8+
* Crypto uses node:crypto (requires the `nodejs_compat` compatibility flag,
9+
* set in wrangler.toml). That API is fully supported in Workers, accepts the
10+
* PEM key directly, and is byte-for-byte the same code path we test locally
11+
* under Node — safer than relying on WebCrypto's Ed25519 algorithm naming,
12+
* which differed historically in the Workers runtime.
1013
*
1114
* Secrets (wrangler secret put ...):
1215
* LICENSE_PRIVATE_KEY_PEM Ed25519 private key, PKCS8 PEM (public half is embedded in the app)
@@ -20,56 +23,39 @@
2023
* LICENSE_PRODUCT_ID optional, default "easypost-desktop"
2124
*/
2225

23-
const enc = new TextEncoder();
24-
const SIGNATURE_TOLERANCE_SECONDS = 300;
25-
26-
function pemToDer(pem) {
27-
const b64 = pem
28-
.replace(/-----BEGIN [^-]+-----/, "")
29-
.replace(/-----END [^-]+-----/, "")
30-
.replace(/\s+/g, "");
31-
const bin = atob(b64);
32-
const der = new Uint8Array(bin.length);
33-
for (let i = 0; i < bin.length; i++) der[i] = bin.charCodeAt(i);
34-
return der;
35-
}
26+
import { createHmac, createPrivateKey, sign as nodeSign, timingSafeEqual } from "node:crypto";
3627

37-
function b64url(bytes) {
38-
let bin = "";
39-
for (const b of bytes) bin += String.fromCharCode(b);
40-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
41-
}
42-
43-
function toHex(buf) {
44-
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
45-
}
28+
const SIGNATURE_TOLERANCE_SECONDS = 300;
4629

47-
function timingSafeEqual(a, b) {
48-
if (a.length !== b.length) return false;
49-
let r = 0;
50-
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
51-
return r === 0;
30+
function b64url(buf) {
31+
return Buffer.from(buf).toString("base64url");
5232
}
5333

54-
async function verifyPaddleSignature(rawBody, sigHeader, secret) {
55-
const parts = Object.fromEntries(
56-
sigHeader.split(";").map((kv) => kv.split("=").map((s) => s.trim()))
57-
);
34+
/** Verify Paddle's `Paddle-Signature: ts=<unix>;h1=<hex hmac of "ts:body">`. */
35+
export function verifyPaddleSignature(rawBody, sigHeader, secret) {
36+
let parts;
37+
try {
38+
parts = Object.fromEntries(
39+
String(sigHeader).split(";").map((kv) => kv.split("=").map((s) => s.trim()))
40+
);
41+
} catch {
42+
return false;
43+
}
5844
const { ts, h1 } = parts;
5945
if (!ts || !h1) return false;
6046
if (Math.abs(Date.now() / 1000 - Number(ts)) > SIGNATURE_TOLERANCE_SECONDS) return false;
61-
const key = await crypto.subtle.importKey(
62-
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
63-
);
64-
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${ts}:${rawBody}`));
65-
return timingSafeEqual(toHex(mac), h1);
47+
const expected = createHmac("sha256", secret).update(`${ts}:${rawBody}`).digest("hex");
48+
const a = Buffer.from(expected, "utf8");
49+
const b = Buffer.from(h1, "utf8");
50+
return a.length === b.length && timingSafeEqual(a, b);
6651
}
6752

68-
async function mintLicense(pem, product, email, order, iat) {
69-
const payloadBytes = enc.encode(JSON.stringify({ v: 1, product, email, order, iat }));
70-
const key = await crypto.subtle.importKey("pkcs8", pemToDer(pem), { name: "Ed25519" }, false, ["sign"]);
71-
const sig = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, payloadBytes));
72-
return `EPD1.${b64url(payloadBytes)}.${b64url(sig)}`;
53+
/** Mint the offline license key the desktop app verifies. */
54+
export function mintLicense(pem, product, email, order, iat) {
55+
const payload = Buffer.from(JSON.stringify({ v: 1, product, email, order, iat }), "utf8");
56+
// Ed25519 takes a null digest algorithm.
57+
const signature = nodeSign(null, payload, createPrivateKey(pem));
58+
return `EPD1.${b64url(payload)}.${b64url(signature)}`;
7359
}
7460

7561
async function getCustomerEmail(base, apiKey, customerId) {
@@ -113,7 +99,7 @@ export default {
11399

114100
const raw = await request.text();
115101
const sig = request.headers.get("Paddle-Signature") || "";
116-
if (!(await verifyPaddleSignature(raw, sig, env.PADDLE_WEBHOOK_SECRET))) {
102+
if (!verifyPaddleSignature(raw, sig, env.PADDLE_WEBHOOK_SECRET)) {
117103
return new Response("invalid signature", { status: 401 });
118104
}
119105

@@ -131,7 +117,7 @@ export default {
131117
const iat = event.occurred_at || "1970-01-01T00:00:00Z";
132118

133119
const email = await getCustomerEmail(base, env.PADDLE_API_KEY, data.customer_id);
134-
const licenseKey = await mintLicense(env.LICENSE_PRIVATE_KEY_PEM, product, email, txn, iat);
120+
const licenseKey = mintLicense(env.LICENSE_PRIVATE_KEY_PEM, product, email, txn, iat);
135121
await sendLicenseEmail(env.RESEND_API_KEY, env.LICENSE_FROM_EMAIL, email, licenseKey);
136122

137123
return json({ status: "license_issued", transaction: txn });

server/paddle-license-webhook-worker/wrangler.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
name = "easypost-license-webhook"
22
main = "src/worker.js"
3-
# Recent date so WebCrypto Ed25519 is available in the Workers runtime.
43
compatibility_date = "2025-06-01"
4+
# node:crypto (Ed25519 signing + HMAC) requires nodejs_compat.
5+
compatibility_flags = ["nodejs_compat"]
56

67
# Non-secret configuration. Set the real values here before deploying.
78
[vars]

0 commit comments

Comments
 (0)