@easypayment/medusa-payment-paypal 0.9.16 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.medusa/server/src/admin/index.js +59 -32
  2. package/.medusa/server/src/admin/index.mjs +59 -32
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +64 -2
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
  8. package/.medusa/server/src/api/middlewares.js +9 -0
  9. package/.medusa/server/src/api/middlewares.js.map +1 -1
  10. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  11. package/.medusa/server/src/api/store/paypal/capture-order/route.js +8 -58
  12. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  13. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/store/paypal/create-order/route.js +17 -43
  15. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  16. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  17. package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
  18. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  19. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  20. package/.medusa/server/src/modules/paypal/payment-provider/service.js +54 -31
  21. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  22. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
  23. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
  24. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
  25. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
  26. package/.medusa/server/src/modules/paypal/service.d.ts +12 -1
  27. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  28. package/.medusa/server/src/modules/paypal/service.js +14 -3
  29. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  30. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts +35 -0
  31. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts.map +1 -0
  32. package/.medusa/server/src/modules/paypal/utils/payment-session.js +78 -0
  33. package/.medusa/server/src/modules/paypal/utils/payment-session.js.map +1 -0
  34. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
  35. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
  36. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
  37. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
  38. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
  39. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
  40. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
  41. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/admin/routes/settings/paypal/connection/page.tsx +68 -12
  44. package/src/api/middlewares.ts +9 -0
  45. package/src/api/store/paypal/capture-order/route.ts +8 -71
  46. package/src/api/store/paypal/create-order/route.ts +25 -47
  47. package/src/api/store/paypal/webhook/route.ts +22 -8
  48. package/src/modules/paypal/payment-provider/service.ts +73 -29
  49. package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
  50. package/src/modules/paypal/service.ts +17 -3
  51. package/src/modules/paypal/utils/payment-session.ts +104 -0
  52. package/src/modules/paypal/utils/secret-crypto.ts +90 -27
  53. package/src/modules/paypal/utils/webhook-verify.ts +61 -0
@@ -1,4 +1,4 @@
1
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "crypto"
2
2
 
3
3
  /**
4
4
  * Opt-in encryption-at-rest for PayPal secrets (client secret, app access
@@ -8,15 +8,35 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypt
8
8
  * default), both functions are the identity — behavior is byte-for-byte
9
9
  * unchanged, so existing deployments are unaffected.
10
10
  *
11
- * Format: `enc:v1:<iv_b64>:<tag_b64>:<ciphertext_b64>` using AES-256-GCM
12
- * (authenticated, so tampering is detected on decrypt). Legacy plaintext values
13
- * (no prefix) are returned as-is on decrypt, so secrets stored before
14
- * encryption was enabled keep working and are upgraded on the next save.
11
+ * Format (current): `enc:v2:<salt_b64>:<iv_b64>:<tag_b64>:<ciphertext_b64>`
12
+ * using AES-256-GCM with a key derived via **scrypt** over the env key and a
13
+ * random per-secret salt. scrypt is a memory-hard KDF, so even a low-entropy
14
+ * passphrase resists brute-force/rainbow-table attacks (the previous unsalted,
15
+ * single-pass SHA-256 did not).
16
+ *
17
+ * Format (legacy): `enc:v1:<iv_b64>:<tag_b64>:<ciphertext_b64>` with an
18
+ * unsalted SHA-256 key. Still decryptable so secrets written before the KDF
19
+ * upgrade keep working; they are transparently upgraded to v2 on the next save.
20
+ *
21
+ * Legacy plaintext values (no prefix) are returned as-is on decrypt.
15
22
  */
16
- const PREFIX = "enc:v1:"
23
+ const PREFIX_V2 = "enc:v2:"
24
+ const PREFIX_V1 = "enc:v1:"
25
+
26
+ // scrypt cost parameters. N=2^14 with r=8 needs ~16MB (128*N*r), which stays
27
+ // under Node's default 32MB scrypt memory limit while imposing a meaningful
28
+ // per-derivation cost. Derived keys are cached, so this runs at most once per
29
+ // (env key, salt) pair rather than on every encrypt/decrypt.
30
+ const SCRYPT_N = 16384
31
+ const SCRYPT_R = 8
32
+ const SCRYPT_P = 1
33
+ const KEY_LEN = 32
34
+ const SALT_LEN = 16
17
35
 
18
36
  let cachedRaw: string | undefined
19
- let cachedKey: Buffer | null = null
37
+ let cachedRawKey: string | null = null
38
+ let legacyV1Key: Buffer | null = null
39
+ const v2KeyCache = new Map<string, Buffer>()
20
40
 
21
41
  /**
22
42
  * `@types/node` 22.0-22.6 typed `Buffer` as `Buffer<ArrayBufferLike>`, which is
@@ -27,41 +47,75 @@ let cachedKey: Buffer | null = null
27
47
  */
28
48
  const bytes = (view: Buffer): Uint8Array => view as unknown as Uint8Array
29
49
 
30
- function getKey(): Buffer | null {
50
+ /** The raw env key (trimmed), or null when encryption is disabled. */
51
+ function getRawKey(): string | null {
31
52
  const raw = (process.env.PAYPAL_ENCRYPTION_KEY || "").trim()
32
53
  if (raw !== cachedRaw) {
33
54
  cachedRaw = raw
34
- // Accept a key of any length/format; derive a stable 32-byte AES key.
35
- cachedKey = raw ? createHash("sha256").update(raw).digest() : null
55
+ cachedRawKey = raw || null
56
+ // The unsalted SHA-256 key is only needed to decrypt legacy v1 values.
57
+ legacyV1Key = raw ? createHash("sha256").update(raw).digest() : null
58
+ v2KeyCache.clear()
59
+ }
60
+ return cachedRawKey
61
+ }
62
+
63
+ /** Derive (and cache) the AES key for a v2 secret from the env key + salt. */
64
+ function deriveV2Key(raw: string, salt: Buffer): Buffer {
65
+ const cacheKey = `${raw}::${salt.toString("base64")}`
66
+ let key = v2KeyCache.get(cacheKey)
67
+ if (!key) {
68
+ key = scryptSync(raw, bytes(salt), KEY_LEN, {
69
+ N: SCRYPT_N,
70
+ r: SCRYPT_R,
71
+ p: SCRYPT_P,
72
+ })
73
+ v2KeyCache.set(cacheKey, key)
36
74
  }
37
- return cachedKey
75
+ return key
76
+ }
77
+
78
+ function gcmDecrypt(key: Buffer, ivB64: string, tagB64: string, ctB64: string): string {
79
+ const decipher = createDecipheriv("aes-256-gcm", bytes(key), bytes(Buffer.from(ivB64, "base64")))
80
+ decipher.setAuthTag(bytes(Buffer.from(tagB64, "base64")))
81
+ const plaintext = Buffer.concat([
82
+ bytes(decipher.update(bytes(Buffer.from(ctB64, "base64")))),
83
+ bytes(decipher.final()),
84
+ ])
85
+ return plaintext.toString("utf8")
38
86
  }
39
87
 
40
88
  export function isSecretEncryptionEnabled(): boolean {
41
- return getKey() !== null
89
+ return getRawKey() !== null
42
90
  }
43
91
 
44
92
  export function isEncrypted(value: unknown): boolean {
45
- return typeof value === "string" && value.startsWith(PREFIX)
93
+ return (
94
+ typeof value === "string" &&
95
+ (value.startsWith(PREFIX_V2) || value.startsWith(PREFIX_V1))
96
+ )
46
97
  }
47
98
 
48
99
  /**
49
100
  * Encrypt a secret for storage. Returns the input unchanged when no key is
50
- * configured or when the value is empty/already encrypted.
101
+ * configured or when the value is empty/already encrypted. Always writes the
102
+ * current (v2) format.
51
103
  */
52
104
  export function encryptSecret<T extends string | null | undefined>(value: T): T | string {
53
105
  if (!value) {
54
106
  return value
55
107
  }
56
- const key = getKey()
57
- if (!key || isEncrypted(value)) {
108
+ const raw = getRawKey()
109
+ if (!raw || isEncrypted(value)) {
58
110
  return value
59
111
  }
112
+ const salt = randomBytes(SALT_LEN)
113
+ const key = deriveV2Key(raw, salt)
60
114
  const iv = randomBytes(12)
61
115
  const cipher = createCipheriv("aes-256-gcm", bytes(key), bytes(iv))
62
116
  const ciphertext = Buffer.concat([bytes(cipher.update(String(value), "utf8")), bytes(cipher.final())])
63
117
  const tag = cipher.getAuthTag()
64
- return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`
118
+ return `${PREFIX_V2}${salt.toString("base64")}:${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`
65
119
  }
66
120
 
67
121
  /**
@@ -73,22 +127,31 @@ export function decryptSecret<T extends string | null | undefined>(value: T): T
73
127
  if (!value || !isEncrypted(value)) {
74
128
  return value
75
129
  }
76
- const key = getKey()
77
- if (!key) {
130
+ const raw = getRawKey()
131
+ if (!raw) {
78
132
  throw new Error(
79
133
  "PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set. Restore the key to decrypt stored credentials."
80
134
  )
81
135
  }
82
- const body = String(value).slice(PREFIX.length)
136
+
137
+ if (String(value).startsWith(PREFIX_V2)) {
138
+ const body = String(value).slice(PREFIX_V2.length)
139
+ const [saltB64, ivB64, tagB64, ctB64] = body.split(":")
140
+ if (!saltB64 || !ivB64 || !tagB64 || !ctB64) {
141
+ throw new Error("PayPal secret ciphertext is malformed.")
142
+ }
143
+ const key = deriveV2Key(raw, Buffer.from(saltB64, "base64"))
144
+ return gcmDecrypt(key, ivB64, tagB64, ctB64)
145
+ }
146
+
147
+ // Legacy v1 (unsalted SHA-256 key).
148
+ const body = String(value).slice(PREFIX_V1.length)
83
149
  const [ivB64, tagB64, ctB64] = body.split(":")
84
150
  if (!ivB64 || !tagB64 || !ctB64) {
85
151
  throw new Error("PayPal secret ciphertext is malformed.")
86
152
  }
87
- const decipher = createDecipheriv("aes-256-gcm", bytes(key), bytes(Buffer.from(ivB64, "base64")))
88
- decipher.setAuthTag(bytes(Buffer.from(tagB64, "base64")))
89
- const plaintext = Buffer.concat([
90
- bytes(decipher.update(bytes(Buffer.from(ctB64, "base64")))),
91
- bytes(decipher.final()),
92
- ])
93
- return plaintext.toString("utf8")
153
+ if (!legacyV1Key) {
154
+ throw new Error("PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set.")
155
+ }
156
+ return gcmDecrypt(legacyV1Key, ivB64, tagB64, ctB64)
94
157
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Helpers for calling PayPal's `verify-webhook-signature` API correctly.
3
+ *
4
+ * PayPal computes the expected signature over the EXACT bytes it transmitted.
5
+ * Re-serializing the parsed body (`JSON.stringify(req.body)`) can change those
6
+ * bytes — key ordering, non-ASCII escaping (`José` vs `José`), number and
7
+ * whitespace formatting — which makes verification intermittently return
8
+ * FAILURE for legitimate webhooks. When the original raw body is available we
9
+ * inject it verbatim as `webhook_event`; otherwise we fall back to serializing
10
+ * the parsed body so behavior never regresses.
11
+ */
12
+
13
+ /** Normalize Medusa's `req.rawBody` (a Buffer when `preserveRawBody` is set) to a string. */
14
+ export function rawBodyToString(rawBody: unknown): string | null {
15
+ if (rawBody == null) {
16
+ return null
17
+ }
18
+ if (typeof rawBody === "string") {
19
+ return rawBody.length > 0 ? rawBody : null
20
+ }
21
+ if (Buffer.isBuffer(rawBody)) {
22
+ return rawBody.length > 0 ? rawBody.toString("utf8") : null
23
+ }
24
+ if (rawBody instanceof Uint8Array) {
25
+ return rawBody.length > 0 ? Buffer.from(rawBody).toString("utf8") : null
26
+ }
27
+ return null
28
+ }
29
+
30
+ /**
31
+ * Compose the JSON request body for `verify-webhook-signature`.
32
+ *
33
+ * `fields` are the transmission headers + webhook_id; `webhookEventJson` is the
34
+ * raw webhook body verbatim (preferred) or a serialized fallback. Undefined /
35
+ * null header values are omitted. The webhook event is concatenated as raw JSON
36
+ * (not re-encoded) so the bytes PayPal hashes match what it signed.
37
+ */
38
+ export function composeVerifyRequestBody(
39
+ fields: Record<string, string | undefined | null>,
40
+ webhookEventJson: string
41
+ ): string {
42
+ const head = Object.entries(fields)
43
+ .filter(([, v]) => v !== undefined && v !== null)
44
+ .map(([k, v]) => `${JSON.stringify(k)}:${JSON.stringify(v)}`)
45
+ .join(",")
46
+ return `{${head}${head ? "," : ""}"webhook_event":${webhookEventJson}}`
47
+ }
48
+
49
+ /**
50
+ * Pick the bytes to use for `webhook_event`: the raw body when present and
51
+ * non-blank, otherwise a JSON serialization of the parsed body.
52
+ */
53
+ export function resolveWebhookEventJson(
54
+ rawBody: string | null | undefined,
55
+ parsedBody: unknown
56
+ ): string {
57
+ if (typeof rawBody === "string" && rawBody.trim().length > 0) {
58
+ return rawBody
59
+ }
60
+ return JSON.stringify(parsedBody ?? {})
61
+ }