@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.
- package/.medusa/server/src/admin/index.js +59 -32
- package/.medusa/server/src/admin/index.mjs +59 -32
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +64 -2
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
- package/.medusa/server/src/api/middlewares.js +9 -0
- package/.medusa/server/src/api/middlewares.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.js +8 -58
- package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/create-order/route.js +17 -43
- package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
- package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.js +54 -31
- package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
- package/.medusa/server/src/modules/paypal/service.d.ts +12 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +14 -3
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts +35 -0
- package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/payment-session.js +78 -0
- package/.medusa/server/src/modules/paypal/utils/payment-session.js.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +68 -12
- package/src/api/middlewares.ts +9 -0
- package/src/api/store/paypal/capture-order/route.ts +8 -71
- package/src/api/store/paypal/create-order/route.ts +25 -47
- package/src/api/store/paypal/webhook/route.ts +22 -8
- package/src/modules/paypal/payment-provider/service.ts +73 -29
- package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
- package/src/modules/paypal/service.ts +17 -3
- package/src/modules/paypal/utils/payment-session.ts +104 -0
- package/src/modules/paypal/utils/secret-crypto.ts +90 -27
- 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:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
89
|
+
return getRawKey() !== null
|
|
42
90
|
}
|
|
43
91
|
|
|
44
92
|
export function isEncrypted(value: unknown): boolean {
|
|
45
|
-
return
|
|
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
|
|
57
|
-
if (!
|
|
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 `${
|
|
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
|
|
77
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|