keytd-manat 0.1.0__tar.gz
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.
- keytd_manat-0.1.0/LICENSE +21 -0
- keytd_manat-0.1.0/PKG-INFO +92 -0
- keytd_manat-0.1.0/README.md +76 -0
- keytd_manat-0.1.0/pyproject.toml +25 -0
- keytd_manat-0.1.0/setup.cfg +4 -0
- keytd_manat-0.1.0/src/keytd_manat.egg-info/PKG-INFO +92 -0
- keytd_manat-0.1.0/src/keytd_manat.egg-info/SOURCES.txt +26 -0
- keytd_manat-0.1.0/src/keytd_manat.egg-info/dependency_links.txt +1 -0
- keytd_manat-0.1.0/src/keytd_manat.egg-info/top_level.txt +1 -0
- keytd_manat-0.1.0/src/manat/__init__.py +12 -0
- keytd_manat-0.1.0/src/manat/_crypto.py +174 -0
- keytd_manat-0.1.0/src/manat/_form.py +35 -0
- keytd_manat-0.1.0/src/manat/_http.py +86 -0
- keytd_manat-0.1.0/src/manat/_json.py +35 -0
- keytd_manat-0.1.0/src/manat/_values.py +72 -0
- keytd_manat-0.1.0/src/manat/client.py +99 -0
- keytd_manat-0.1.0/src/manat/errors.py +34 -0
- keytd_manat-0.1.0/src/manat/money.py +79 -0
- keytd_manat-0.1.0/src/manat/providers/__init__.py +8 -0
- keytd_manat-0.1.0/src/manat/providers/azericard.py +318 -0
- keytd_manat-0.1.0/src/manat/providers/epoint.py +142 -0
- keytd_manat-0.1.0/src/manat/providers/goldenpay.py +153 -0
- keytd_manat-0.1.0/src/manat/providers/kapitalbank.py +172 -0
- keytd_manat-0.1.0/src/manat/providers/payriff.py +134 -0
- keytd_manat-0.1.0/src/manat/providers/yigim.py +152 -0
- keytd_manat-0.1.0/src/manat/types.py +163 -0
- keytd_manat-0.1.0/tests/test_units.py +119 -0
- keytd_manat-0.1.0/tests/test_vectors.py +120 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KeyTD
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keytd-manat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
|
|
5
|
+
Author-email: KeyTD <info@keytd.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/KeyTechAndDesign/manat
|
|
8
|
+
Project-URL: Issues, https://github.com/KeyTechAndDesign/manat/issues
|
|
9
|
+
Keywords: azerbaijan,payments,azn,manat,payment-gateway,kapital-bank,azericard,payriff,epoint,goldenpay,yigim
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Manat for Python
|
|
18
|
+
|
|
19
|
+
One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
|
|
20
|
+
Python 3.10+, no dependencies (standard library only, RSA included).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install keytd-manat
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from manat import Manat, azn
|
|
28
|
+
from manat.providers import KapitalBank
|
|
29
|
+
|
|
30
|
+
pay = Manat(KapitalBank(username=os.environ["KB_USER"], password=os.environ["KB_PASSWORD"]))
|
|
31
|
+
|
|
32
|
+
result = pay.create_payment("A-1001", azn("25.50"), "https://shop.az/payment/return") # 2550 qəpik
|
|
33
|
+
if result.next.type == "redirect":
|
|
34
|
+
return redirect(result.next.url)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Store `result.payment_id` with the order. It is what `get_status`, `refund`, `capture` and `cancel` take.
|
|
38
|
+
|
|
39
|
+
## Providers
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from manat.providers import Azericard, Epoint, GoldenPay, KapitalBank, Payriff, Yigim
|
|
43
|
+
|
|
44
|
+
KapitalBank(username, password, environment="test")
|
|
45
|
+
Azericard(terminal, private_key_pem, "My Shop", "https://shop.az", gateway_public_key=gateway_pem, environment="test")
|
|
46
|
+
Payriff(secret_key)
|
|
47
|
+
Epoint(public_key, private_key)
|
|
48
|
+
GoldenPay(auth_key, merchant_name, environment="test")
|
|
49
|
+
Yigim(merchant, api_key, biller, environment="test")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Provider | create_payment | verify_callback | get_status | refund | capture | cancel |
|
|
53
|
+
|--------------|:-:|:-:|:-:|:-:|:-:|:-:|
|
|
54
|
+
| Kapital Bank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
55
|
+
| Azericard | ✓ (form POST) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
56
|
+
| Payriff | ✓ | ✓ | ✓ | ✓ | ✓ | — |
|
|
57
|
+
| Epoint | ✓ | ✓ | ✓ | ✓ | — | — |
|
|
58
|
+
| GoldenPay | ✓ | ✓ | ✓ | ✓ | — | — |
|
|
59
|
+
| Yığım | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
60
|
+
|
|
61
|
+
Every provider takes `base_url` (or `gateway_url`), `transport` and `timeout` keyword arguments.
|
|
62
|
+
|
|
63
|
+
## Callbacks
|
|
64
|
+
|
|
65
|
+
Pass what your handler received, with the raw body. Unsigned redirects and webhooks are never trusted: the adapter
|
|
66
|
+
re-reads the payment from the provider's API.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
event = pay.verify_callback(request.method, query=request.args, body=request.get_data(as_text=True))
|
|
70
|
+
if event.status == "paid":
|
|
71
|
+
... # fulfil event.payment_id; for Azericard store event.provider_ref for refunds
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Refunds, capture, cancel
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
pay.refund(payment_id) # full refund
|
|
78
|
+
pay.refund(payment_id, amount=azn("10.00")) # partial
|
|
79
|
+
pay.capture(payment_id) # after intent="authorize"
|
|
80
|
+
pay.refund(payment_id, provider_ref=event.provider_ref) # Azericard
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
Everything raises `manat.ManatError`. `error.code` is one of `invalid_request`, `invalid_signature`, `provider_error`,
|
|
86
|
+
`network_error`, `unsupported_operation`; `provider_code`, `provider_message` and `raw` hold what the provider said.
|
|
87
|
+
|
|
88
|
+
## Tests
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd python && PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
92
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Manat for Python
|
|
2
|
+
|
|
3
|
+
One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
|
|
4
|
+
Python 3.10+, no dependencies (standard library only, RSA included).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install keytd-manat
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from manat import Manat, azn
|
|
12
|
+
from manat.providers import KapitalBank
|
|
13
|
+
|
|
14
|
+
pay = Manat(KapitalBank(username=os.environ["KB_USER"], password=os.environ["KB_PASSWORD"]))
|
|
15
|
+
|
|
16
|
+
result = pay.create_payment("A-1001", azn("25.50"), "https://shop.az/payment/return") # 2550 qəpik
|
|
17
|
+
if result.next.type == "redirect":
|
|
18
|
+
return redirect(result.next.url)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Store `result.payment_id` with the order. It is what `get_status`, `refund`, `capture` and `cancel` take.
|
|
22
|
+
|
|
23
|
+
## Providers
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from manat.providers import Azericard, Epoint, GoldenPay, KapitalBank, Payriff, Yigim
|
|
27
|
+
|
|
28
|
+
KapitalBank(username, password, environment="test")
|
|
29
|
+
Azericard(terminal, private_key_pem, "My Shop", "https://shop.az", gateway_public_key=gateway_pem, environment="test")
|
|
30
|
+
Payriff(secret_key)
|
|
31
|
+
Epoint(public_key, private_key)
|
|
32
|
+
GoldenPay(auth_key, merchant_name, environment="test")
|
|
33
|
+
Yigim(merchant, api_key, biller, environment="test")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Provider | create_payment | verify_callback | get_status | refund | capture | cancel |
|
|
37
|
+
|--------------|:-:|:-:|:-:|:-:|:-:|:-:|
|
|
38
|
+
| Kapital Bank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
39
|
+
| Azericard | ✓ (form POST) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
40
|
+
| Payriff | ✓ | ✓ | ✓ | ✓ | ✓ | — |
|
|
41
|
+
| Epoint | ✓ | ✓ | ✓ | ✓ | — | — |
|
|
42
|
+
| GoldenPay | ✓ | ✓ | ✓ | ✓ | — | — |
|
|
43
|
+
| Yığım | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
44
|
+
|
|
45
|
+
Every provider takes `base_url` (or `gateway_url`), `transport` and `timeout` keyword arguments.
|
|
46
|
+
|
|
47
|
+
## Callbacks
|
|
48
|
+
|
|
49
|
+
Pass what your handler received, with the raw body. Unsigned redirects and webhooks are never trusted: the adapter
|
|
50
|
+
re-reads the payment from the provider's API.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
event = pay.verify_callback(request.method, query=request.args, body=request.get_data(as_text=True))
|
|
54
|
+
if event.status == "paid":
|
|
55
|
+
... # fulfil event.payment_id; for Azericard store event.provider_ref for refunds
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Refunds, capture, cancel
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
pay.refund(payment_id) # full refund
|
|
62
|
+
pay.refund(payment_id, amount=azn("10.00")) # partial
|
|
63
|
+
pay.capture(payment_id) # after intent="authorize"
|
|
64
|
+
pay.refund(payment_id, provider_ref=event.provider_ref) # Azericard
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Errors
|
|
68
|
+
|
|
69
|
+
Everything raises `manat.ManatError`. `error.code` is one of `invalid_request`, `invalid_signature`, `provider_error`,
|
|
70
|
+
`network_error`, `unsupported_operation`; `provider_code`, `provider_message` and `raw` hold what the provider said.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cd python && PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
76
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keytd-manat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{name = "KeyTD", email = "info@keytd.com"}]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["azerbaijan", "payments", "azn", "manat", "payment-gateway", "kapital-bank", "azericard", "payriff", "epoint", "goldenpay", "yigim"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Office/Business :: Financial",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/KeyTechAndDesign/manat"
|
|
22
|
+
Issues = "https://github.com/KeyTechAndDesign/manat/issues"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keytd-manat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
|
|
5
|
+
Author-email: KeyTD <info@keytd.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/KeyTechAndDesign/manat
|
|
8
|
+
Project-URL: Issues, https://github.com/KeyTechAndDesign/manat/issues
|
|
9
|
+
Keywords: azerbaijan,payments,azn,manat,payment-gateway,kapital-bank,azericard,payriff,epoint,goldenpay,yigim
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Manat for Python
|
|
18
|
+
|
|
19
|
+
One API for Azerbaijani online payment providers: Kapital Bank, Azericard, Payriff, Epoint, GoldenPay and Yığım.
|
|
20
|
+
Python 3.10+, no dependencies (standard library only, RSA included).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install keytd-manat
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from manat import Manat, azn
|
|
28
|
+
from manat.providers import KapitalBank
|
|
29
|
+
|
|
30
|
+
pay = Manat(KapitalBank(username=os.environ["KB_USER"], password=os.environ["KB_PASSWORD"]))
|
|
31
|
+
|
|
32
|
+
result = pay.create_payment("A-1001", azn("25.50"), "https://shop.az/payment/return") # 2550 qəpik
|
|
33
|
+
if result.next.type == "redirect":
|
|
34
|
+
return redirect(result.next.url)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Store `result.payment_id` with the order. It is what `get_status`, `refund`, `capture` and `cancel` take.
|
|
38
|
+
|
|
39
|
+
## Providers
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from manat.providers import Azericard, Epoint, GoldenPay, KapitalBank, Payriff, Yigim
|
|
43
|
+
|
|
44
|
+
KapitalBank(username, password, environment="test")
|
|
45
|
+
Azericard(terminal, private_key_pem, "My Shop", "https://shop.az", gateway_public_key=gateway_pem, environment="test")
|
|
46
|
+
Payriff(secret_key)
|
|
47
|
+
Epoint(public_key, private_key)
|
|
48
|
+
GoldenPay(auth_key, merchant_name, environment="test")
|
|
49
|
+
Yigim(merchant, api_key, biller, environment="test")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Provider | create_payment | verify_callback | get_status | refund | capture | cancel |
|
|
53
|
+
|--------------|:-:|:-:|:-:|:-:|:-:|:-:|
|
|
54
|
+
| Kapital Bank | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
55
|
+
| Azericard | ✓ (form POST) | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
56
|
+
| Payriff | ✓ | ✓ | ✓ | ✓ | ✓ | — |
|
|
57
|
+
| Epoint | ✓ | ✓ | ✓ | ✓ | — | — |
|
|
58
|
+
| GoldenPay | ✓ | ✓ | ✓ | ✓ | — | — |
|
|
59
|
+
| Yığım | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
60
|
+
|
|
61
|
+
Every provider takes `base_url` (or `gateway_url`), `transport` and `timeout` keyword arguments.
|
|
62
|
+
|
|
63
|
+
## Callbacks
|
|
64
|
+
|
|
65
|
+
Pass what your handler received, with the raw body. Unsigned redirects and webhooks are never trusted: the adapter
|
|
66
|
+
re-reads the payment from the provider's API.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
event = pay.verify_callback(request.method, query=request.args, body=request.get_data(as_text=True))
|
|
70
|
+
if event.status == "paid":
|
|
71
|
+
... # fulfil event.payment_id; for Azericard store event.provider_ref for refunds
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Refunds, capture, cancel
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
pay.refund(payment_id) # full refund
|
|
78
|
+
pay.refund(payment_id, amount=azn("10.00")) # partial
|
|
79
|
+
pay.capture(payment_id) # after intent="authorize"
|
|
80
|
+
pay.refund(payment_id, provider_ref=event.provider_ref) # Azericard
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
Everything raises `manat.ManatError`. `error.code` is one of `invalid_request`, `invalid_signature`, `provider_error`,
|
|
86
|
+
`network_error`, `unsupported_operation`; `provider_code`, `provider_message` and `raw` hold what the provider said.
|
|
87
|
+
|
|
88
|
+
## Tests
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd python && PYTHONPATH=src:tests python3 -m unittest discover -s tests
|
|
92
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/keytd_manat.egg-info/PKG-INFO
|
|
5
|
+
src/keytd_manat.egg-info/SOURCES.txt
|
|
6
|
+
src/keytd_manat.egg-info/dependency_links.txt
|
|
7
|
+
src/keytd_manat.egg-info/top_level.txt
|
|
8
|
+
src/manat/__init__.py
|
|
9
|
+
src/manat/_crypto.py
|
|
10
|
+
src/manat/_form.py
|
|
11
|
+
src/manat/_http.py
|
|
12
|
+
src/manat/_json.py
|
|
13
|
+
src/manat/_values.py
|
|
14
|
+
src/manat/client.py
|
|
15
|
+
src/manat/errors.py
|
|
16
|
+
src/manat/money.py
|
|
17
|
+
src/manat/types.py
|
|
18
|
+
src/manat/providers/__init__.py
|
|
19
|
+
src/manat/providers/azericard.py
|
|
20
|
+
src/manat/providers/epoint.py
|
|
21
|
+
src/manat/providers/goldenpay.py
|
|
22
|
+
src/manat/providers/kapitalbank.py
|
|
23
|
+
src/manat/providers/payriff.py
|
|
24
|
+
src/manat/providers/yigim.py
|
|
25
|
+
tests/test_units.py
|
|
26
|
+
tests/test_vectors.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
manat
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""One API for Azerbaijani online payment providers."""
|
|
2
|
+
|
|
3
|
+
from .client import Manat
|
|
4
|
+
from .errors import ManatError, is_manat_error
|
|
5
|
+
from .money import Money, azn, from_decimal
|
|
6
|
+
from .types import CreatePaymentResult, IncomingCallback, NextAction, PaymentEvent, PaymentState, RefundResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Manat", "ManatError", "is_manat_error", "Money", "azn", "from_decimal",
|
|
10
|
+
"CreatePaymentResult", "IncomingCallback", "NextAction", "PaymentEvent", "PaymentState", "RefundResult",
|
|
11
|
+
]
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Hashes, constant-time comparison and RSA-SHA256 (PKCS#1 v1.5) on the standard library alone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
_PEM = re.compile(r"-----BEGIN ([A-Z0-9 ]+)-----([A-Za-z0-9+/=\s]+)-----END \1-----")
|
|
15
|
+
# DigestInfo prefix for SHA-256 (RFC 8017, section 9.2).
|
|
16
|
+
_SHA256_PREFIX = bytes.fromhex("3031300d060960864801650304020105000420")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def md5_hex(text: str) -> str:
|
|
20
|
+
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sha1_base64(text: str) -> str:
|
|
24
|
+
return base64.b64encode(hashlib.sha1(text.encode("utf-8")).digest()).decode("ascii")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def safe_equals(actual: Optional[str], expected: str) -> bool:
|
|
28
|
+
return isinstance(actual, str) and hmac.compare_digest(actual.encode("utf-8"), expected.encode("utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RsaPublicKey:
|
|
33
|
+
n: int
|
|
34
|
+
e: int
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def bits(self) -> int:
|
|
38
|
+
return self.n.bit_length()
|
|
39
|
+
|
|
40
|
+
def verify_sha256(self, text: str, signature: bytes) -> bool:
|
|
41
|
+
k = (self.n.bit_length() + 7) // 8
|
|
42
|
+
if len(signature) != k:
|
|
43
|
+
return False
|
|
44
|
+
s = int.from_bytes(signature, "big")
|
|
45
|
+
if s >= self.n:
|
|
46
|
+
return False
|
|
47
|
+
return hmac.compare_digest(pow(s, self.e, self.n).to_bytes(k, "big"), _encode(text, k))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RsaPrivateKey:
|
|
52
|
+
n: int
|
|
53
|
+
e: int
|
|
54
|
+
d: int
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def bits(self) -> int:
|
|
58
|
+
return self.n.bit_length()
|
|
59
|
+
|
|
60
|
+
def sign_sha256(self, text: str) -> bytes:
|
|
61
|
+
k = (self.n.bit_length() + 7) // 8
|
|
62
|
+
m = int.from_bytes(_encode(text, k), "big")
|
|
63
|
+
# Blinding keeps the private-key exponentiation's timing independent of the message.
|
|
64
|
+
while True:
|
|
65
|
+
r = secrets.randbelow(self.n - 2) + 2
|
|
66
|
+
if math.gcd(r, self.n) == 1:
|
|
67
|
+
break
|
|
68
|
+
blinded = pow(m * pow(r, self.e, self.n) % self.n, self.d, self.n)
|
|
69
|
+
return (blinded * pow(r, -1, self.n) % self.n).to_bytes(k, "big")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def private_key(pem: str) -> RsaPrivateKey:
|
|
73
|
+
label, der = _block(pem)
|
|
74
|
+
if label == "RSA PRIVATE KEY":
|
|
75
|
+
return _rsa_private(der)
|
|
76
|
+
if label == "PRIVATE KEY":
|
|
77
|
+
# PrivateKeyInfo { version, AlgorithmIdentifier, OCTET STRING { RSAPrivateKey } }
|
|
78
|
+
info = _Der(_Der(der).read(0x30))
|
|
79
|
+
info.read(0x02)
|
|
80
|
+
info.read(0x30)
|
|
81
|
+
return _rsa_private(info.read(0x04))
|
|
82
|
+
raise ValueError(f'unsupported PEM block "{label}" (encrypted keys are not supported)')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def public_key(pem: str) -> RsaPublicKey:
|
|
86
|
+
label, der = _block(pem)
|
|
87
|
+
if label == "RSA PUBLIC KEY":
|
|
88
|
+
return _rsa_public(der)
|
|
89
|
+
if label == "PUBLIC KEY":
|
|
90
|
+
return _spki(der)
|
|
91
|
+
if label == "CERTIFICATE":
|
|
92
|
+
# Certificate { TBSCertificate { [0] version?, serial, signature, issuer, validity, subject, spki, … }, … }
|
|
93
|
+
tbs = _Der(_Der(_Der(der).read(0x30)).read(0x30))
|
|
94
|
+
if tbs.peek() == 0xA0:
|
|
95
|
+
tbs.read(0xA0)
|
|
96
|
+
for tag in (0x02, 0x30, 0x30, 0x30, 0x30):
|
|
97
|
+
tbs.read(tag)
|
|
98
|
+
return _spki(_wrap(0x30, tbs.read(0x30)))
|
|
99
|
+
raise ValueError(f'unsupported PEM block "{label}"')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _encode(text: str, k: int) -> bytes:
|
|
103
|
+
"""EMSA-PKCS1-v1_5: 00 01 FF…FF 00 DigestInfo(SHA-256)."""
|
|
104
|
+
t = _SHA256_PREFIX + hashlib.sha256(text.encode("utf-8")).digest()
|
|
105
|
+
if k < len(t) + 11:
|
|
106
|
+
raise ValueError("key too short")
|
|
107
|
+
return b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _block(pem: str) -> Tuple[str, bytes]:
|
|
111
|
+
match = _PEM.search(pem or "")
|
|
112
|
+
if not match:
|
|
113
|
+
raise ValueError("not a PEM key")
|
|
114
|
+
return match.group(1), base64.b64decode(re.sub(r"\s", "", match.group(2)), validate=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _rsa_private(der: bytes) -> RsaPrivateKey:
|
|
118
|
+
key = _Der(_Der(der).read(0x30))
|
|
119
|
+
key.read(0x02) # version
|
|
120
|
+
n, e, d = (int.from_bytes(key.read(0x02), "big") for _ in range(3))
|
|
121
|
+
return RsaPrivateKey(n, e, d)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rsa_public(der: bytes) -> RsaPublicKey:
|
|
125
|
+
key = _Der(_Der(der).read(0x30))
|
|
126
|
+
n, e = (int.from_bytes(key.read(0x02), "big") for _ in range(2))
|
|
127
|
+
return RsaPublicKey(n, e)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _spki(der: bytes) -> RsaPublicKey:
|
|
131
|
+
# SubjectPublicKeyInfo { AlgorithmIdentifier, BIT STRING { 00, RSAPublicKey } }
|
|
132
|
+
info = _Der(_Der(der).read(0x30))
|
|
133
|
+
info.read(0x30)
|
|
134
|
+
return _rsa_public(info.read(0x03)[1:])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _wrap(tag: int, content: bytes) -> bytes:
|
|
138
|
+
length = len(content)
|
|
139
|
+
if length < 0x80:
|
|
140
|
+
return bytes([tag, length]) + content
|
|
141
|
+
size = (length.bit_length() + 7) // 8
|
|
142
|
+
return bytes([tag, 0x80 | size]) + length.to_bytes(size, "big") + content
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Der:
|
|
146
|
+
"""Just enough DER reading for RSA keys."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, data: bytes) -> None:
|
|
149
|
+
self.data = data
|
|
150
|
+
self.pos = 0
|
|
151
|
+
|
|
152
|
+
def peek(self) -> int:
|
|
153
|
+
return self.data[self.pos] if self.pos < len(self.data) else -1
|
|
154
|
+
|
|
155
|
+
def read(self, tag: int) -> bytes:
|
|
156
|
+
if self.peek() != tag:
|
|
157
|
+
raise ValueError(f"bad key: expected DER tag 0x{tag:02x}")
|
|
158
|
+
self.pos += 1
|
|
159
|
+
length = self.data[self.pos]
|
|
160
|
+
self.pos += 1
|
|
161
|
+
if length & 0x80:
|
|
162
|
+
count = length & 0x7F
|
|
163
|
+
if not 1 <= count <= 4:
|
|
164
|
+
raise ValueError("bad key: DER length")
|
|
165
|
+
length = int.from_bytes(self.data[self.pos:self.pos + count], "big")
|
|
166
|
+
self.pos += count
|
|
167
|
+
if self.pos + length > len(self.data):
|
|
168
|
+
raise ValueError("bad key: truncated")
|
|
169
|
+
value = self.data[self.pos:self.pos + length]
|
|
170
|
+
self.pos += length
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__: List[str] = ["md5_hex", "sha1_base64", "safe_equals", "private_key", "public_key", "RsaPrivateKey", "RsaPublicKey"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Iterable, Mapping, Tuple, Union
|
|
4
|
+
from urllib.parse import unquote_plus
|
|
5
|
+
|
|
6
|
+
_SAFE = frozenset(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789*-._")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def encode(fields: Union[Mapping[str, str], Iterable[Tuple[str, str]]]) -> str:
|
|
10
|
+
"""application/x-www-form-urlencoded, encoded like JavaScript's URLSearchParams."""
|
|
11
|
+
pairs = fields.items() if isinstance(fields, Mapping) else fields
|
|
12
|
+
return "&".join(f"{_escape(key)}={_escape(value)}" for key, value in pairs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode(body: str) -> Dict[str, str]:
|
|
16
|
+
"""Later duplicates are ignored; malformed %-escapes are kept as text."""
|
|
17
|
+
out: Dict[str, str] = {}
|
|
18
|
+
for pair in (body or "").split("&"):
|
|
19
|
+
if not pair:
|
|
20
|
+
continue
|
|
21
|
+
key, _, value = pair.partition("=")
|
|
22
|
+
out.setdefault(unquote_plus(key), unquote_plus(value))
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _escape(text: str) -> str:
|
|
27
|
+
out = []
|
|
28
|
+
for byte in (text or "").encode("utf-8"):
|
|
29
|
+
if byte in _SAFE:
|
|
30
|
+
out.append(chr(byte))
|
|
31
|
+
elif byte == 0x20:
|
|
32
|
+
out.append("+")
|
|
33
|
+
else:
|
|
34
|
+
out.append(f"%{byte:02X}")
|
|
35
|
+
return "".join(out)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Dict, Mapping, Optional, Protocol, Tuple
|
|
8
|
+
|
|
9
|
+
from . import _form, _json
|
|
10
|
+
from .errors import NETWORK_ERROR, ManatError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class HttpResponse:
|
|
15
|
+
status: int
|
|
16
|
+
text: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Transport(Protocol):
|
|
20
|
+
"""Sends one request. Raise OSError (or http.client.HTTPException) on a network failure."""
|
|
21
|
+
|
|
22
|
+
def send(self, method: str, url: str, headers: Mapping[str, str], body: Optional[bytes], timeout: float) -> HttpResponse: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
26
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UrllibTransport:
|
|
31
|
+
"""Default transport on urllib: TLS verified, redirects not followed."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
35
|
+
|
|
36
|
+
def send(self, method: str, url: str, headers: Mapping[str, str], body: Optional[bytes], timeout: float) -> HttpResponse:
|
|
37
|
+
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
|
38
|
+
try:
|
|
39
|
+
with self._opener.open(request, timeout=timeout) as response:
|
|
40
|
+
return HttpResponse(response.status, _decode(response.read(), response.headers.get_content_charset()))
|
|
41
|
+
except urllib.error.HTTPError as error:
|
|
42
|
+
return HttpResponse(error.code, _decode(error.read(), error.headers.get_content_charset() if error.headers else None))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Http:
|
|
46
|
+
"""Encodes bodies, applies the timeout and turns failures into ManatError for one adapter."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, provider: str, transport: Optional[Transport], timeout: float) -> None:
|
|
49
|
+
self.provider = provider
|
|
50
|
+
self.transport: Transport = transport or UrllibTransport()
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
|
|
53
|
+
def send(self, method: str, url: str, headers: Optional[Mapping[str, str]] = None, json: Any = None,
|
|
54
|
+
form: Optional[Mapping[str, str]] = None) -> HttpResponse:
|
|
55
|
+
lower: Dict[str, str] = {name.lower(): value for name, value in (headers or {}).items()}
|
|
56
|
+
body: Optional[bytes] = None
|
|
57
|
+
if form is not None:
|
|
58
|
+
body = _form.encode(form).encode("utf-8")
|
|
59
|
+
lower.setdefault("content-type", "application/x-www-form-urlencoded")
|
|
60
|
+
elif json is not None:
|
|
61
|
+
body = _json.stringify(json).encode("utf-8")
|
|
62
|
+
lower.setdefault("content-type", "application/json")
|
|
63
|
+
try:
|
|
64
|
+
return self.transport.send(method, url, lower, body, self.timeout)
|
|
65
|
+
except ManatError:
|
|
66
|
+
raise
|
|
67
|
+
except (OSError, http.client.HTTPException) as error:
|
|
68
|
+
raise ManatError(NETWORK_ERROR, f"{self.provider}: {method} {url} {error}", provider=self.provider) from error
|
|
69
|
+
|
|
70
|
+
def json(self, method: str, url: str, headers: Optional[Mapping[str, str]] = None, json: Any = None,
|
|
71
|
+
form: Optional[Mapping[str, str]] = None) -> Tuple[int, Any]:
|
|
72
|
+
response = self.send(method, url, {"accept": "application/json", **{k.lower(): v for k, v in (headers or {}).items()}}, json, form)
|
|
73
|
+
if not response.text:
|
|
74
|
+
return response.status, None
|
|
75
|
+
try:
|
|
76
|
+
return response.status, _json.parse(response.text)
|
|
77
|
+
except ValueError as error:
|
|
78
|
+
raise ManatError(NETWORK_ERROR, f'{self.provider}: expected JSON, got HTTP {response.status} "{response.text[:200]}"',
|
|
79
|
+
provider=self.provider, raw=response.text) from error
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _decode(data: bytes, charset: Optional[str]) -> str:
|
|
83
|
+
try:
|
|
84
|
+
return data.decode(charset or "utf-8")
|
|
85
|
+
except (LookupError, UnicodeDecodeError):
|
|
86
|
+
return data.decode("utf-8", errors="replace")
|