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)
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 ( / - - - - - B E G I N [ ^ - ] + - - - - - / , "" )
29- . replace ( / - - - - - E N D [ ^ - ] + - - - - - / , "" )
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
7561async 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 } ) ;
0 commit comments