python-redsys 1.2.2__py3-none-any.whl → 2.1.0__py3-none-any.whl
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.
- python_redsys-2.1.0.dist-info/METADATA +225 -0
- python_redsys-2.1.0.dist-info/RECORD +20 -0
- {python_redsys-1.2.2.dist-info → python_redsys-2.1.0.dist-info}/WHEEL +2 -1
- python_redsys-2.1.0.dist-info/licenses/LICENSE +202 -0
- python_redsys-2.1.0.dist-info/licenses/NOTICE +7 -0
- python_redsys-2.1.0.dist-info/top_level.txt +1 -0
- redsys/__init__.py +3 -2
- redsys/api/__init__.py +1 -0
- redsys/api/cof.py +29 -0
- redsys/api/currencies.py +84 -0
- redsys/api/languages.py +67 -0
- redsys/api/sca.py +23 -0
- redsys/api/transactions.py +74 -0
- redsys/clients.py +277 -0
- redsys/crypto.py +123 -0
- redsys/exceptions.py +19 -0
- redsys/models.py +358 -0
- redsys/py.typed +1 -0
- redsys/request.py +54 -157
- redsys/threeds.py +67 -0
- python_redsys-1.2.2.dist-info/METADATA +0 -122
- python_redsys-1.2.2.dist-info/RECORD +0 -13
- python_redsys-1.2.2.dist-info/licenses/LICENSE +0 -25
- redsys/client.py +0 -105
- redsys/constants.py +0 -113
- redsys/response.py +0 -149
- redsys/tests/__init__.py +0 -0
- redsys/tests/test_client.py +0 -89
- redsys/tests/test_request.py +0 -74
- redsys/tests/test_response.py +0 -39
redsys/api/sca.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""PSD2 strong customer authentication (SCA) exemption codes for Ds_Merchant_Excep_SCA (REST guide 4.0.1)."""
|
|
2
|
+
|
|
3
|
+
LWV = 'LWV'
|
|
4
|
+
"""Low-value transaction: up to 30 EUR, with issuer-side counters (5 operations or 100 EUR accumulated)."""
|
|
5
|
+
|
|
6
|
+
TRA = 'TRA'
|
|
7
|
+
"""Transaction risk analysis performed by the acquirer or the merchant."""
|
|
8
|
+
|
|
9
|
+
MIT = 'MIT'
|
|
10
|
+
"""Merchant-initiated transaction, no cardholder interaction (stored credentials, subscriptions).
|
|
11
|
+
|
|
12
|
+
Sent together with direct_payment "true"."""
|
|
13
|
+
|
|
14
|
+
COR = 'COR'
|
|
15
|
+
"""Secure corporate payment, business-to-business only."""
|
|
16
|
+
|
|
17
|
+
ATD = 'ATD'
|
|
18
|
+
"""Delegated authentication, a card-scheme program."""
|
|
19
|
+
|
|
20
|
+
EXEMPTIONS = (LWV, TRA, MIT, COR, ATD)
|
|
21
|
+
|
|
22
|
+
QUERY = 'Y'
|
|
23
|
+
"""Sent on the 3DS initialization to ask which exemptions the card allows; the answer comes back in Ds_Excep_SCA."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
AUTHORIZATION = 0
|
|
2
|
+
"""Authorization."""
|
|
3
|
+
|
|
4
|
+
PREAUTHORIZATION = 1
|
|
5
|
+
"""Pre-authorization."""
|
|
6
|
+
|
|
7
|
+
CONFIRMATION = 2
|
|
8
|
+
"""Confirmation."""
|
|
9
|
+
|
|
10
|
+
REFUND = 3
|
|
11
|
+
"""Refund."""
|
|
12
|
+
|
|
13
|
+
AUTHENTICATION = 7
|
|
14
|
+
"""Authentication."""
|
|
15
|
+
|
|
16
|
+
AUTHENTICATION_CONFIRMATION = 8
|
|
17
|
+
"""Authentication confirmation"""
|
|
18
|
+
|
|
19
|
+
CANCELLATION = 9
|
|
20
|
+
"""Cancellation"""
|
|
21
|
+
|
|
22
|
+
REPLACEMENT_PREAUTHORIZATION = 11
|
|
23
|
+
"""Replacement preauthorization: extends an existing preauthorization."""
|
|
24
|
+
|
|
25
|
+
PAY_GOLD = 15
|
|
26
|
+
"""Pay gold"""
|
|
27
|
+
|
|
28
|
+
AUTHENTICATION_PUCE = 17
|
|
29
|
+
"""PUCE authentication"""
|
|
30
|
+
|
|
31
|
+
REFUND_WITHOUT_ORIGINAL = 34
|
|
32
|
+
"""Refund without original"""
|
|
33
|
+
|
|
34
|
+
BETTING_PRICE = 37
|
|
35
|
+
"""Betting price"""
|
|
36
|
+
|
|
37
|
+
PAY_GOLD_AUTHORIZATION = 38
|
|
38
|
+
"""PayGold authorization: the cardholder completes a PayGold request (type 15); sent by RedSys, not the merchant."""
|
|
39
|
+
|
|
40
|
+
DELETE_REFERENCE = 44
|
|
41
|
+
"""Delete a stored card reference (token)."""
|
|
42
|
+
|
|
43
|
+
PAYMENT_CANCELLATION = 45
|
|
44
|
+
"""Payment cancellation, for merchant time-outs or technical errors."""
|
|
45
|
+
|
|
46
|
+
REFUND_CANCELLATION = 46
|
|
47
|
+
"""Refund cancellation, within 48 hours of the refund."""
|
|
48
|
+
|
|
49
|
+
AUTHENTICATION_CONFIRMATION_CANCELLATION = 47
|
|
50
|
+
"""Cancellation of an authentication confirmation (type 8)."""
|
|
51
|
+
|
|
52
|
+
PARTIAL_CONFIRMATION = 48
|
|
53
|
+
"""Partial confirmation of a preauthorization."""
|
|
54
|
+
|
|
55
|
+
TRANSACTION_TYPES = (
|
|
56
|
+
AUTHORIZATION,
|
|
57
|
+
PREAUTHORIZATION,
|
|
58
|
+
CONFIRMATION,
|
|
59
|
+
REFUND,
|
|
60
|
+
AUTHENTICATION,
|
|
61
|
+
AUTHENTICATION_CONFIRMATION,
|
|
62
|
+
CANCELLATION,
|
|
63
|
+
REPLACEMENT_PREAUTHORIZATION,
|
|
64
|
+
PAY_GOLD,
|
|
65
|
+
AUTHENTICATION_PUCE,
|
|
66
|
+
REFUND_WITHOUT_ORIGINAL,
|
|
67
|
+
BETTING_PRICE,
|
|
68
|
+
PAY_GOLD_AUTHORIZATION,
|
|
69
|
+
DELETE_REFERENCE,
|
|
70
|
+
PAYMENT_CANCELLATION,
|
|
71
|
+
REFUND_CANCELLATION,
|
|
72
|
+
AUTHENTICATION_CONFIRMATION_CANCELLATION,
|
|
73
|
+
PARTIAL_CONFIRMATION,
|
|
74
|
+
)
|
redsys/clients.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Client entry points for the RedSys platform.
|
|
2
|
+
|
|
3
|
+
The client is the single entry point: it holds the credentials and account
|
|
4
|
+
identity (secret key, merchant code, terminal, signature version) and acts as
|
|
5
|
+
the factory for every operation. You never build or import a request model --
|
|
6
|
+
call an operation method (``payment``, ``refund``, ...) or the low-level
|
|
7
|
+
``request()`` they all delegate to, and you get back a ``Request``:
|
|
8
|
+
|
|
9
|
+
client = DirectClient(secret_key='...', merchant_code='...', terminal=1, sandbox=True)
|
|
10
|
+
response = client.payment(order='000000001', amount=Decimal('10.56'), currency=978).send()
|
|
11
|
+
|
|
12
|
+
Redirect requests end in ``.to_dict()`` (the browser submits them); direct
|
|
13
|
+
requests end in ``.send()`` (the library performs the REST call). Both verify
|
|
14
|
+
and parse incoming results through ``client.parse_response(payload)``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from hmac import compare_digest
|
|
18
|
+
from logging import getLogger
|
|
19
|
+
from re import sub
|
|
20
|
+
|
|
21
|
+
from redsys.api import transactions
|
|
22
|
+
from redsys.crypto import (
|
|
23
|
+
DEFAULT_SIGNATURE_VERSION,
|
|
24
|
+
HMAC_SHA256_V1,
|
|
25
|
+
HMAC_SHA512_V1,
|
|
26
|
+
HMAC_SHA512_V2,
|
|
27
|
+
SIGNATURE_VERSIONS,
|
|
28
|
+
get_signer,
|
|
29
|
+
)
|
|
30
|
+
from redsys.exceptions import ResponseError
|
|
31
|
+
from redsys.models import (
|
|
32
|
+
CancellationMessage,
|
|
33
|
+
ConfirmationMessage,
|
|
34
|
+
DirectPaymentMessage,
|
|
35
|
+
DirectPreauthorizationMessage,
|
|
36
|
+
RedirectPaymentMessage,
|
|
37
|
+
RedirectPreauthorizationMessage,
|
|
38
|
+
RefundMessage,
|
|
39
|
+
Response,
|
|
40
|
+
)
|
|
41
|
+
from redsys.request import (
|
|
42
|
+
MERCHANT_PARAMETERS,
|
|
43
|
+
SIGNATURE,
|
|
44
|
+
SIGNATURE_VERSION,
|
|
45
|
+
Request,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
logger = getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# Default HTTP timeout (seconds) for direct-connection network calls; override per call.
|
|
51
|
+
DEFAULT_TIMEOUT = 30
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
'Client',
|
|
55
|
+
'RedirectClient',
|
|
56
|
+
'DirectClient',
|
|
57
|
+
'AsyncDirectClient',
|
|
58
|
+
'Request',
|
|
59
|
+
'SIGNATURE_VERSION',
|
|
60
|
+
'MERCHANT_PARAMETERS',
|
|
61
|
+
'SIGNATURE',
|
|
62
|
+
'HMAC_SHA256_V1',
|
|
63
|
+
'HMAC_SHA512_V1',
|
|
64
|
+
'HMAC_SHA512_V2',
|
|
65
|
+
'SIGNATURE_VERSIONS',
|
|
66
|
+
'DEFAULT_SIGNATURE_VERSION',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_signature(signature) -> str:
|
|
71
|
+
"""Reduce a signature to its alphanumeric core for comparison.
|
|
72
|
+
|
|
73
|
+
RedSys returns response signatures in URL-safe Base64 ('-', '_') while
|
|
74
|
+
standard Base64 uses ('+', '/'). Comparing only the alphanumeric characters
|
|
75
|
+
makes the two alphabets and any padding compare equal.
|
|
76
|
+
"""
|
|
77
|
+
if isinstance(signature, bytes):
|
|
78
|
+
signature = signature.decode('utf-8')
|
|
79
|
+
return sub(r'[^a-zA-Z0-9]', '', signature)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Client:
|
|
83
|
+
"""Base client: credentials, account identity, request factory, response parsing."""
|
|
84
|
+
|
|
85
|
+
REAL_ENDPOINT: str | None = None
|
|
86
|
+
TEST_ENDPOINT: str | None = None
|
|
87
|
+
|
|
88
|
+
#: transaction_type -> request model. Unmapped types fall back to _DEFAULT_MODEL.
|
|
89
|
+
_DEFAULT_MODEL: type | None = None
|
|
90
|
+
_MODELS: dict[int, type] = {}
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
secret_key: str,
|
|
95
|
+
*,
|
|
96
|
+
merchant_code: str | None = None,
|
|
97
|
+
terminal: int | None = None,
|
|
98
|
+
sandbox: bool = False,
|
|
99
|
+
signature_version: str = DEFAULT_SIGNATURE_VERSION,
|
|
100
|
+
):
|
|
101
|
+
self._secret_key = secret_key
|
|
102
|
+
self.merchant_code = merchant_code
|
|
103
|
+
self.terminal = terminal
|
|
104
|
+
self.sandbox = sandbox
|
|
105
|
+
self.signature_version = signature_version
|
|
106
|
+
self.endpoint = self.TEST_ENDPOINT if sandbox else self.REAL_ENDPOINT
|
|
107
|
+
|
|
108
|
+
# -- request building -------------------------------------------------
|
|
109
|
+
def _inject(self, fields: dict) -> dict:
|
|
110
|
+
"""Merge the client's account identity into a call's fields (call wins)."""
|
|
111
|
+
data: dict = {}
|
|
112
|
+
if self.merchant_code is not None:
|
|
113
|
+
data['merchant_code'] = self.merchant_code
|
|
114
|
+
if self.terminal is not None:
|
|
115
|
+
data['terminal'] = self.terminal
|
|
116
|
+
data.update(fields)
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
def _model_for(self, transaction_type: int) -> type:
|
|
120
|
+
model = self._MODELS.get(transaction_type, self._DEFAULT_MODEL)
|
|
121
|
+
if model is None:
|
|
122
|
+
raise NotImplementedError('This client defines no request model; use a concrete subclass.')
|
|
123
|
+
return model
|
|
124
|
+
|
|
125
|
+
def request(self, transaction_type: int, **fields) -> Request:
|
|
126
|
+
"""Build, validate and sign any operation by its transaction type.
|
|
127
|
+
|
|
128
|
+
The single funnel every named operation delegates to. Use it directly
|
|
129
|
+
for transaction types without a dedicated method (e.g. ``PAY_GOLD``).
|
|
130
|
+
"""
|
|
131
|
+
data = self._inject(fields)
|
|
132
|
+
data.setdefault('transaction_type', transaction_type)
|
|
133
|
+
model = self._model_for(transaction_type)(**data)
|
|
134
|
+
return self._prepare(model)
|
|
135
|
+
|
|
136
|
+
def _prepare(self, model) -> Request:
|
|
137
|
+
return self._build_request(model)
|
|
138
|
+
|
|
139
|
+
def _build_request(self, model, signature_version: str | None = None, sender=None) -> Request:
|
|
140
|
+
version = signature_version or self.signature_version
|
|
141
|
+
signer = get_signer(self._secret_key, version)
|
|
142
|
+
merchant_parameters = signer.encode_parameters(model.prepare_parameters())
|
|
143
|
+
signature = signer.sign(model.get_order(), merchant_parameters)
|
|
144
|
+
return Request(merchant_parameters, signature, version, sender=sender)
|
|
145
|
+
|
|
146
|
+
# -- response ---------------------------------------------------------
|
|
147
|
+
def parse_response(self, payload: dict) -> Response:
|
|
148
|
+
"""Verify the signature and parse a received payload into a ``Response``.
|
|
149
|
+
|
|
150
|
+
Accepts both the redirect callback form fields and the direct REST JSON
|
|
151
|
+
body. Raises ``ValueError`` if the signature does not match (the payload
|
|
152
|
+
is not from RedSys or was tampered with), or ``ResponseError`` if RedSys
|
|
153
|
+
answered with an error code instead of a signed result.
|
|
154
|
+
"""
|
|
155
|
+
if MERCHANT_PARAMETERS not in payload:
|
|
156
|
+
error_code = payload.get('errorCode') or payload.get('Ds_ErrorCode')
|
|
157
|
+
raise ResponseError(error_code, payload)
|
|
158
|
+
merchant_parameters = payload[MERCHANT_PARAMETERS]
|
|
159
|
+
if isinstance(merchant_parameters, str):
|
|
160
|
+
merchant_parameters = merchant_parameters.encode('utf-8')
|
|
161
|
+
version = payload.get(SIGNATURE_VERSION, DEFAULT_SIGNATURE_VERSION)
|
|
162
|
+
response = Response.from_merchant_parameters(merchant_parameters)
|
|
163
|
+
# A signed result always carries Ds_Order; '' can never match, so a
|
|
164
|
+
# missing order simply fails verification below.
|
|
165
|
+
expected = get_signer(self._secret_key, version).sign(response.get_order() or '', merchant_parameters)
|
|
166
|
+
# Constant-time comparison to avoid leaking the signature via timing.
|
|
167
|
+
if not compare_digest(_normalize_signature(payload[SIGNATURE]), _normalize_signature(expected)):
|
|
168
|
+
raise ValueError('Signature is not valid.')
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class RedirectClient(Client):
|
|
173
|
+
"""Redirect connection (secure method): builds the signed POST parameters the
|
|
174
|
+
browser submits to RedSys. The library performs no HTTP and no redirect."""
|
|
175
|
+
|
|
176
|
+
# Endpoints per the redirection guide 4.2 (RS.TE.CEL.MAN.0039).
|
|
177
|
+
REAL_ENDPOINT = 'https://sis.redsys.es/sis/realizarPago'
|
|
178
|
+
TEST_ENDPOINT = 'https://sis-t.redsys.es:25443/sis/realizarPago'
|
|
179
|
+
_DEFAULT_MODEL = RedirectPaymentMessage
|
|
180
|
+
_MODELS = {transactions.PREAUTHORIZATION: RedirectPreauthorizationMessage}
|
|
181
|
+
|
|
182
|
+
def payment(self, **fields) -> Request:
|
|
183
|
+
return self.request(transactions.AUTHORIZATION, **fields)
|
|
184
|
+
|
|
185
|
+
def preauthorization(self, **fields) -> Request:
|
|
186
|
+
return self.request(transactions.PREAUTHORIZATION, **fields)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _require_httpx():
|
|
190
|
+
try:
|
|
191
|
+
import httpx
|
|
192
|
+
except ImportError as exc:
|
|
193
|
+
raise ImportError('Sending requires httpx. Install with: pip install "python-redsys[http]".') from exc
|
|
194
|
+
return httpx
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _DirectBase(Client):
|
|
198
|
+
"""Shared REST direct-connection logic (transport-agnostic)."""
|
|
199
|
+
|
|
200
|
+
# Endpoints per the REST guide 4.0.1 (RS.TE.CEL.MAN.0037).
|
|
201
|
+
REAL_ENDPOINT = 'https://sis.redsys.es/sis/rest/trataPeticionREST'
|
|
202
|
+
TEST_ENDPOINT = 'https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST'
|
|
203
|
+
INICIA_REAL_ENDPOINT = 'https://sis.redsys.es/sis/rest/iniciaPeticionREST'
|
|
204
|
+
INICIA_TEST_ENDPOINT = 'https://sis-t.redsys.es:25443/sis/rest/iniciaPeticionREST'
|
|
205
|
+
|
|
206
|
+
_DEFAULT_MODEL = DirectPaymentMessage
|
|
207
|
+
_MODELS = {
|
|
208
|
+
transactions.PREAUTHORIZATION: DirectPreauthorizationMessage,
|
|
209
|
+
transactions.CONFIRMATION: ConfirmationMessage,
|
|
210
|
+
transactions.REFUND: RefundMessage,
|
|
211
|
+
transactions.CANCELLATION: CancellationMessage,
|
|
212
|
+
transactions.REPLACEMENT_PREAUTHORIZATION: DirectPreauthorizationMessage,
|
|
213
|
+
transactions.PARTIAL_CONFIRMATION: ConfirmationMessage,
|
|
214
|
+
transactions.PAYMENT_CANCELLATION: CancellationMessage,
|
|
215
|
+
transactions.REFUND_CANCELLATION: CancellationMessage,
|
|
216
|
+
transactions.AUTHENTICATION_CONFIRMATION_CANCELLATION: CancellationMessage,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def inicia_endpoint(self) -> str:
|
|
221
|
+
return self.INICIA_TEST_ENDPOINT if self.sandbox else self.INICIA_REAL_ENDPOINT
|
|
222
|
+
|
|
223
|
+
def payment(self, **fields) -> Request:
|
|
224
|
+
return self.request(transactions.AUTHORIZATION, **fields)
|
|
225
|
+
|
|
226
|
+
def preauthorization(self, **fields) -> Request:
|
|
227
|
+
return self.request(transactions.PREAUTHORIZATION, **fields)
|
|
228
|
+
|
|
229
|
+
def confirmation(self, **fields) -> Request:
|
|
230
|
+
return self.request(transactions.CONFIRMATION, **fields)
|
|
231
|
+
|
|
232
|
+
def refund(self, **fields) -> Request:
|
|
233
|
+
return self.request(transactions.REFUND, **fields)
|
|
234
|
+
|
|
235
|
+
def cancellation(self, **fields) -> Request:
|
|
236
|
+
return self.request(transactions.CANCELLATION, **fields)
|
|
237
|
+
|
|
238
|
+
def inicia(self, **fields) -> Request:
|
|
239
|
+
"""3-D Secure step 1: build a request bound to the iniciaPeticionREST endpoint."""
|
|
240
|
+
model = self._DEFAULT_MODEL(**self._inject(fields))
|
|
241
|
+
return self._prepare(model, endpoint=self.inicia_endpoint)
|
|
242
|
+
|
|
243
|
+
def _prepare(self, model, endpoint: str | None = None) -> Request:
|
|
244
|
+
endpoint = endpoint or self.endpoint
|
|
245
|
+
return self._build_request(model, sender=self._make_sender(endpoint))
|
|
246
|
+
|
|
247
|
+
def _make_sender(self, endpoint):
|
|
248
|
+
raise NotImplementedError
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class DirectClient(_DirectBase):
|
|
252
|
+
"""Synchronous REST direct-connection client. Requires the httpx extra."""
|
|
253
|
+
|
|
254
|
+
def _make_sender(self, endpoint):
|
|
255
|
+
def sender(request, **httpx_kwargs):
|
|
256
|
+
httpx = _require_httpx()
|
|
257
|
+
httpx_kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
258
|
+
http_response = httpx.post(endpoint, json=request.to_dict(), **httpx_kwargs)
|
|
259
|
+
http_response.raise_for_status()
|
|
260
|
+
return self.parse_response(http_response.json())
|
|
261
|
+
|
|
262
|
+
return sender
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class AsyncDirectClient(_DirectBase):
|
|
266
|
+
"""Asynchronous REST direct-connection client (httpx.AsyncClient). Requires the httpx extra."""
|
|
267
|
+
|
|
268
|
+
def _make_sender(self, endpoint):
|
|
269
|
+
async def sender(request, **httpx_kwargs):
|
|
270
|
+
httpx = _require_httpx()
|
|
271
|
+
httpx_kwargs.setdefault('timeout', DEFAULT_TIMEOUT)
|
|
272
|
+
async with httpx.AsyncClient() as client:
|
|
273
|
+
http_response = await client.post(endpoint, json=request.to_dict(), **httpx_kwargs)
|
|
274
|
+
http_response.raise_for_status()
|
|
275
|
+
return self.parse_response(http_response.json())
|
|
276
|
+
|
|
277
|
+
return sender
|
redsys/crypto.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Signature computation for the RedSys message envelope.
|
|
2
|
+
|
|
3
|
+
Every version derives a per-operation key by encrypting the order number under
|
|
4
|
+
the merchant secret, then HMACs the Base64 merchant parameters with that key.
|
|
5
|
+
They differ in cipher, padding, hash, and output encoding:
|
|
6
|
+
|
|
7
|
+
| Version | Cipher | Key | Order padding | HMAC | Signature encoding |
|
|
8
|
+
|------------------|---------|---------------------------|---------------|---------|-------------------------|
|
|
9
|
+
| HMAC_SHA256_V1 | 3DES | Base64-decoded secret | zero | SHA-256 | Base64 |
|
|
10
|
+
| HMAC_SHA512_V1 | AES-128 | first 16 chars of secret | PKCS7 | SHA-512 | Base64URL, '=' kept |
|
|
11
|
+
| HMAC_SHA512_V2 | AES-128 | first 16 chars of secret | PKCS7 | SHA-512 | Base64URL, '=' stripped |
|
|
12
|
+
|
|
13
|
+
HMAC_SHA512_V2 also sends Ds_MerchantParameters as unpadded Base64URL; the other
|
|
14
|
+
versions send standard Base64. Responses are decoded tolerantly in either form.
|
|
15
|
+
|
|
16
|
+
The SHA-512 versions key their HMAC with the Base64 text of the derived key and differ
|
|
17
|
+
only in the padding of the signature (RedSys PHP library 2.0.1, Signature.php);
|
|
18
|
+
HMAC_SHA256_V1 uses the raw derived bytes.
|
|
19
|
+
|
|
20
|
+
HMAC_SHA512_V1 is the REST guide 4.0.1 scheme; HMAC_SHA512_V2 (the default) is the one the
|
|
21
|
+
redirection guide 4.1 and later and RedSys's own libraries use; HMAC_SHA256_V1 is
|
|
22
|
+
still accepted (REST guide 4.0.1, Anexo 2). The cipher parameters are pinned by a
|
|
23
|
+
known-answer test against RedSys's official worked example (see tests/test_crypto.py).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from base64 import b64decode, b64encode, urlsafe_b64encode
|
|
27
|
+
from hashlib import sha256, sha512
|
|
28
|
+
from hmac import HMAC
|
|
29
|
+
from json import dumps
|
|
30
|
+
|
|
31
|
+
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES # 3DES, in decrepit since cryptography 43
|
|
32
|
+
from cryptography.hazmat.primitives import padding
|
|
33
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
34
|
+
|
|
35
|
+
HMAC_SHA256_V1 = 'HMAC_SHA256_V1'
|
|
36
|
+
HMAC_SHA512_V1 = 'HMAC_SHA512_V1'
|
|
37
|
+
HMAC_SHA512_V2 = 'HMAC_SHA512_V2'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Signer:
|
|
41
|
+
"""Computes the signature for a single signature version."""
|
|
42
|
+
|
|
43
|
+
version: str
|
|
44
|
+
|
|
45
|
+
def __init__(self, secret_key: str):
|
|
46
|
+
self._secret_key = secret_key
|
|
47
|
+
|
|
48
|
+
def encode_parameters(self, parameters: dict) -> bytes:
|
|
49
|
+
"""Encode the wire dict as the Ds_MerchantParameters string this version expects."""
|
|
50
|
+
return b64encode(dumps(parameters, sort_keys=True).encode('utf-8'))
|
|
51
|
+
|
|
52
|
+
def _derive_key(self, order: str) -> bytes:
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
def sign(self, order: str, merchant_parameters: bytes) -> bytes:
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Sha256V1Signer(Signer):
|
|
60
|
+
version = HMAC_SHA256_V1
|
|
61
|
+
|
|
62
|
+
def _derive_key(self, order: str) -> bytes:
|
|
63
|
+
key = b64decode(self._secret_key)
|
|
64
|
+
data = order.encode('utf-8')
|
|
65
|
+
data += b'\x00' * (-len(data) % 8) # zero-pad to the 3DES block
|
|
66
|
+
encryptor = Cipher(TripleDES(key), modes.CBC(b'\x00' * 8)).encryptor()
|
|
67
|
+
return encryptor.update(data) + encryptor.finalize()
|
|
68
|
+
|
|
69
|
+
def sign(self, order: str, merchant_parameters: bytes) -> bytes:
|
|
70
|
+
derived = self._derive_key(order)
|
|
71
|
+
return b64encode(HMAC(derived, merchant_parameters, sha256).digest())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Sha512V1Signer(Signer):
|
|
75
|
+
version = HMAC_SHA512_V1
|
|
76
|
+
|
|
77
|
+
def _derive_key(self, order: str) -> bytes:
|
|
78
|
+
# Secret truncated to 16 chars, right-padded with '0' if shorter (REST manual v4.0.1).
|
|
79
|
+
key = self._secret_key[:16].ljust(16, '0').encode('utf-8')
|
|
80
|
+
padder = padding.PKCS7(128).padder() # AES block size in bits
|
|
81
|
+
data = padder.update(order.encode('utf-8')) + padder.finalize()
|
|
82
|
+
encryptor = Cipher(algorithms.AES(key), modes.CBC(b'\x00' * 16)).encryptor()
|
|
83
|
+
return encryptor.update(data) + encryptor.finalize()
|
|
84
|
+
|
|
85
|
+
def sign(self, order: str, merchant_parameters: bytes) -> bytes:
|
|
86
|
+
# The HMAC key is the Base64 text of the derived key, not its raw bytes
|
|
87
|
+
# (confirmed live against the RedSys test environment; raw bytes -> SIS0042).
|
|
88
|
+
derived = b64encode(self._derive_key(order))
|
|
89
|
+
# REST manual: Base64URL with the '=' padding kept.
|
|
90
|
+
return urlsafe_b64encode(HMAC(derived, merchant_parameters, sha512).digest())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Sha512V2Signer(Sha512V1Signer):
|
|
94
|
+
version = HMAC_SHA512_V2
|
|
95
|
+
|
|
96
|
+
def encode_parameters(self, parameters: dict) -> bytes:
|
|
97
|
+
# V2 endpoints decode Ds_MerchantParameters as unpadded Base64URL: the 3DS
|
|
98
|
+
# initialization rejects standard or padded Base64 with SIS0430 (verified
|
|
99
|
+
# live 2026-10-25), and the RedSys PHP library sends it this way.
|
|
100
|
+
return urlsafe_b64encode(dumps(parameters, sort_keys=True).encode('utf-8')).rstrip(b'=')
|
|
101
|
+
|
|
102
|
+
def sign(self, order: str, merchant_parameters: bytes) -> bytes:
|
|
103
|
+
# Same derived key and HMAC as HMAC_SHA512_V1; only the '=' padding goes
|
|
104
|
+
# (RedSys PHP library 2.0.1, base64_url_encode_safe).
|
|
105
|
+
return super().sign(order, merchant_parameters).rstrip(b'=')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_SIGNERS = {
|
|
109
|
+
HMAC_SHA256_V1: Sha256V1Signer,
|
|
110
|
+
HMAC_SHA512_V1: Sha512V1Signer,
|
|
111
|
+
HMAC_SHA512_V2: Sha512V2Signer,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
SIGNATURE_VERSIONS = tuple(_SIGNERS)
|
|
115
|
+
DEFAULT_SIGNATURE_VERSION = HMAC_SHA512_V2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_signer(secret_key: str, signature_version: str = DEFAULT_SIGNATURE_VERSION) -> Signer:
|
|
119
|
+
try:
|
|
120
|
+
signer_class = _SIGNERS[signature_version]
|
|
121
|
+
except KeyError:
|
|
122
|
+
raise ValueError(f'Unsupported signature version: {signature_version!r}') from None
|
|
123
|
+
return signer_class(secret_key)
|
redsys/exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class RedsysError(Exception):
|
|
2
|
+
"""Base class for redsys errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResponseError(RedsysError):
|
|
6
|
+
"""RedSys returned an error payload instead of a signed result."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, error_code, payload=None):
|
|
9
|
+
self.error_code = error_code
|
|
10
|
+
self.payload = payload
|
|
11
|
+
super().__init__(f'RedSys returned error: {error_code}')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationError(RedsysError):
|
|
15
|
+
"""A parameter failed validation."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnknownParameterWarning(UserWarning):
|
|
19
|
+
"""A request carries a RedSys wire-name parameter this library version does not model; it is sent as given."""
|