py-dgii-ecf 0.2.1__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.
- py_dgii_ecf-0.2.1/.gitignore +21 -0
- py_dgii_ecf-0.2.1/.python-version +1 -0
- py_dgii_ecf-0.2.1/CHANGELOG.md +17 -0
- py_dgii_ecf-0.2.1/PKG-INFO +109 -0
- py_dgii_ecf-0.2.1/README.md +83 -0
- py_dgii_ecf-0.2.1/pyproject.toml +86 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/__init__.py +104 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/_json.py +13 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/certificate.py +172 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/cli.py +117 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/client.py +325 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/environment.py +60 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/errors.py +232 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/http.py +178 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/py.typed +1 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/resilience.py +92 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/result.py +19 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/signing.py +196 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/transformer.py +142 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/types.py +48 -0
- py_dgii_ecf-0.2.1/src/py_dgii_ecf/utils.py +247 -0
- py_dgii_ecf-0.2.1/tests/conftest.py +38 -0
- py_dgii_ecf-0.2.1/tests/test_cli.py +77 -0
- py_dgii_ecf-0.2.1/tests/test_client.py +141 -0
- py_dgii_ecf-0.2.1/tests/test_resilience.py +31 -0
- py_dgii_ecf-0.2.1/tests/test_result.py +46 -0
- py_dgii_ecf-0.2.1/tests/test_signing.py +93 -0
- py_dgii_ecf-0.2.1/tests/test_transformer.py +45 -0
- py_dgii_ecf-0.2.1/tests/test_utils.py +93 -0
- py_dgii_ecf-0.2.1/uv.lock +1024 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.venv/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
|
|
14
|
+
# Secrets — never commit keys, certs, or credentials
|
|
15
|
+
*.p12
|
|
16
|
+
*.pfx
|
|
17
|
+
*.pem
|
|
18
|
+
*.key
|
|
19
|
+
*.crt
|
|
20
|
+
.env
|
|
21
|
+
.env.*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.1](https://github.com/deuriib/dgii-ecf/compare/python-v0.2.0...python-v0.2.1) (2026-11-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **python:** surface authentication failures in the status CLI command ([4005634](https://github.com/deuriib/dgii-ecf/commit/400563462d14d965a521c2275fc8707d24801a3a))
|
|
9
|
+
* surface CLI auth failures and fix release dispatch ([779d0e7](https://github.com/deuriib/dgii-ecf/commit/779d0e7cc0b4e0ec9ffc69d3410990d930734e80))
|
|
10
|
+
|
|
11
|
+
## [0.2.0](https://github.com/deuriib/dgii-ecf/compare/python-v0.1.0...python-v0.2.0) (2026-11-02)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* **python:** add py-dgii-ecf SDK ([3a4796b](https://github.com/deuriib/dgii-ecf/commit/3a4796b9471f2999a109cc3f69fb464b71804161))
|
|
17
|
+
* scaffold dgii-ecf monorepo with TypeScript and Python SDKs ([41ce340](https://github.com/deuriib/dgii-ecf/commit/41ce34089949c1d38e70fe95d2aa281fa8b8fe87))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: py-dgii-ecf
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: DGII e-CF electronic invoicing SDK for the Dominican Republic — authenticate, sign (XMLDSig/C14N) and transmit electronic tax documents.
|
|
5
|
+
Author: Deuri Vasquez
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dgii,ecf,electronica,facturacion,invoice,ncf,republica-dominicana,xml
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: cryptography>=43.0
|
|
19
|
+
Requires-Dist: httpx2<3,>=2.13
|
|
20
|
+
Requires-Dist: lxml>=5.0
|
|
21
|
+
Requires-Dist: pybreaker>=1.0
|
|
22
|
+
Requires-Dist: result<1,>=0.17
|
|
23
|
+
Requires-Dist: tenacity>=9.0
|
|
24
|
+
Requires-Dist: typer>=0.15
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# py-dgii-ecf
|
|
28
|
+
|
|
29
|
+
> DGII e-CF electronic invoicing SDK for the Dominican Republic (Python).
|
|
30
|
+
|
|
31
|
+
Result-pattern (`result`), resilient HTTP (`httpx2` + `tenacity` + `pybreaker`),
|
|
32
|
+
and XMLDSig signing (`lxml` + `cryptography`). No hidden exceptions — every
|
|
33
|
+
fallible call returns a `Result`.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv add py-dgii-ecf
|
|
39
|
+
# or
|
|
40
|
+
pip install py-dgii-ecf
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> Server-side only. Never load in a context that exposes private keys.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from py_dgii_ecf import ECF, Environment, P12Reader, Signature
|
|
49
|
+
|
|
50
|
+
reader = P12Reader(password)
|
|
51
|
+
credentials = reader.get_key_from_file("/secure/identity.p12")
|
|
52
|
+
if credentials.is_err():
|
|
53
|
+
raise SystemExit(credentials.unwrap_err())
|
|
54
|
+
|
|
55
|
+
ecf = ECF(credentials.unwrap(), Environment.CERT)
|
|
56
|
+
auth = ecf.authenticate()
|
|
57
|
+
if auth.is_err():
|
|
58
|
+
raise SystemExit(auth.unwrap_err())
|
|
59
|
+
|
|
60
|
+
signature = Signature(credentials.unwrap().key, credentials.unwrap().cert)
|
|
61
|
+
signed = signature.sign_xml(xml)
|
|
62
|
+
if signed.is_err():
|
|
63
|
+
raise SystemExit(signed.unwrap_err())
|
|
64
|
+
|
|
65
|
+
response = ecf.send_electronic_document(signed.unwrap(), "101672919E3100000001.xml")
|
|
66
|
+
response.map(lambda invoice: print(invoice.get("trackId"))).map_err(
|
|
67
|
+
lambda error: print(error.code, error)
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Result pattern
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
result = ecf.status_track_id("some-track-id")
|
|
75
|
+
if result.is_ok():
|
|
76
|
+
print(result.unwrap().get("estado"))
|
|
77
|
+
else:
|
|
78
|
+
error = result.unwrap_err()
|
|
79
|
+
print(error.code, error.message)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Resilience & idempotency
|
|
83
|
+
|
|
84
|
+
- Transient failures (network, timeout, 408/429/5xx) are retried with
|
|
85
|
+
exponential backoff and guarded by a `pybreaker` circuit breaker.
|
|
86
|
+
- `send_electronic_document` / `send_summary` are keyed by
|
|
87
|
+
`RNC + eNCF + SHA-256(signed XML)`. Provide a persistent `IdempotencyStore`
|
|
88
|
+
in production.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
ecf = ECF(credentials, Environment.PROD, idempotency_store=my_redis_store)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## CLI
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
py-dgii-ecf sign invoice.xml --p12 identity.p12 --output signed.xml
|
|
98
|
+
py-dgii-ecf authenticate --p12 identity.p12 --environment CertEcf
|
|
99
|
+
py-dgii-ecf status <track-id> --p12 identity.p12
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Modules
|
|
103
|
+
|
|
104
|
+
`environment`, `types`, `errors`, `result`, `resilience`, `http`, `certificate`,
|
|
105
|
+
`signing`, `transformer`, `utils`, `client`, `cli`.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT © 2026 Deuri Vasquez
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# py-dgii-ecf
|
|
2
|
+
|
|
3
|
+
> DGII e-CF electronic invoicing SDK for the Dominican Republic (Python).
|
|
4
|
+
|
|
5
|
+
Result-pattern (`result`), resilient HTTP (`httpx2` + `tenacity` + `pybreaker`),
|
|
6
|
+
and XMLDSig signing (`lxml` + `cryptography`). No hidden exceptions — every
|
|
7
|
+
fallible call returns a `Result`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add py-dgii-ecf
|
|
13
|
+
# or
|
|
14
|
+
pip install py-dgii-ecf
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> Server-side only. Never load in a context that exposes private keys.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from py_dgii_ecf import ECF, Environment, P12Reader, Signature
|
|
23
|
+
|
|
24
|
+
reader = P12Reader(password)
|
|
25
|
+
credentials = reader.get_key_from_file("/secure/identity.p12")
|
|
26
|
+
if credentials.is_err():
|
|
27
|
+
raise SystemExit(credentials.unwrap_err())
|
|
28
|
+
|
|
29
|
+
ecf = ECF(credentials.unwrap(), Environment.CERT)
|
|
30
|
+
auth = ecf.authenticate()
|
|
31
|
+
if auth.is_err():
|
|
32
|
+
raise SystemExit(auth.unwrap_err())
|
|
33
|
+
|
|
34
|
+
signature = Signature(credentials.unwrap().key, credentials.unwrap().cert)
|
|
35
|
+
signed = signature.sign_xml(xml)
|
|
36
|
+
if signed.is_err():
|
|
37
|
+
raise SystemExit(signed.unwrap_err())
|
|
38
|
+
|
|
39
|
+
response = ecf.send_electronic_document(signed.unwrap(), "101672919E3100000001.xml")
|
|
40
|
+
response.map(lambda invoice: print(invoice.get("trackId"))).map_err(
|
|
41
|
+
lambda error: print(error.code, error)
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Result pattern
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
result = ecf.status_track_id("some-track-id")
|
|
49
|
+
if result.is_ok():
|
|
50
|
+
print(result.unwrap().get("estado"))
|
|
51
|
+
else:
|
|
52
|
+
error = result.unwrap_err()
|
|
53
|
+
print(error.code, error.message)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Resilience & idempotency
|
|
57
|
+
|
|
58
|
+
- Transient failures (network, timeout, 408/429/5xx) are retried with
|
|
59
|
+
exponential backoff and guarded by a `pybreaker` circuit breaker.
|
|
60
|
+
- `send_electronic_document` / `send_summary` are keyed by
|
|
61
|
+
`RNC + eNCF + SHA-256(signed XML)`. Provide a persistent `IdempotencyStore`
|
|
62
|
+
in production.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
ecf = ECF(credentials, Environment.PROD, idempotency_store=my_redis_store)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## CLI
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
py-dgii-ecf sign invoice.xml --p12 identity.p12 --output signed.xml
|
|
72
|
+
py-dgii-ecf authenticate --p12 identity.p12 --environment CertEcf
|
|
73
|
+
py-dgii-ecf status <track-id> --p12 identity.p12
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Modules
|
|
77
|
+
|
|
78
|
+
`environment`, `types`, `errors`, `result`, `resilience`, `http`, `certificate`,
|
|
79
|
+
`signing`, `transformer`, `utils`, `client`, `cli`.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT © 2026 Deuri Vasquez
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "py-dgii-ecf"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "DGII e-CF electronic invoicing SDK for the Dominican Republic — authenticate, sign (XMLDSig/C14N) and transmit electronic tax documents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Deuri Vasquez" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"dgii",
|
|
15
|
+
"ecf",
|
|
16
|
+
"facturacion",
|
|
17
|
+
"electronica",
|
|
18
|
+
"republica-dominicana",
|
|
19
|
+
"invoice",
|
|
20
|
+
"ncf",
|
|
21
|
+
"xml",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"httpx2>=2.13,<3",
|
|
36
|
+
"tenacity>=9.0",
|
|
37
|
+
"pybreaker>=1.0",
|
|
38
|
+
"result>=0.17,<1",
|
|
39
|
+
"lxml>=5.0",
|
|
40
|
+
"cryptography>=43.0",
|
|
41
|
+
"typer>=0.15",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"ruff>=0.9",
|
|
47
|
+
"mypy>=1.13",
|
|
48
|
+
"pytest>=8.3",
|
|
49
|
+
"pytest-cov>=6.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.scripts]
|
|
53
|
+
py-dgii-ecf = "py_dgii_ecf.cli:app"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/py_dgii_ecf"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 88
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
src = ["src", "tests"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "B", "UP", "SIM", "RUF", "C4", "PTH", "PLC", "PLE"]
|
|
65
|
+
ignore = ["E501"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.isort]
|
|
68
|
+
known-first-party = ["py_dgii_ecf"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
python_version = "3.11"
|
|
72
|
+
strict = true
|
|
73
|
+
warn_unused_ignores = true
|
|
74
|
+
disallow_any_explicit = true
|
|
75
|
+
ignore_missing_imports = true
|
|
76
|
+
|
|
77
|
+
[[tool.mypy.overrides]]
|
|
78
|
+
module = ["pybreaker.*", "lxml.*", "result.*", "tenacity.*"]
|
|
79
|
+
ignore_missing_imports = true
|
|
80
|
+
|
|
81
|
+
[tool.pytest.ini_options]
|
|
82
|
+
testpaths = ["tests"]
|
|
83
|
+
addopts = "-q"
|
|
84
|
+
|
|
85
|
+
[tool.coverage.run]
|
|
86
|
+
source = ["py_dgii_ecf"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""DGII e-CF electronic invoicing SDK for the Dominican Republic.
|
|
2
|
+
|
|
3
|
+
Result-pattern (``result``), resilient HTTP (``httpx2`` + ``tenacity`` +
|
|
4
|
+
``pybreaker``), and XMLDSig signing (``lxml`` + ``cryptography``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._json import JsonObject, JsonValue
|
|
10
|
+
from .certificate import CertificateInfo, P12Reader, P12ReaderData
|
|
11
|
+
from .client import ECF
|
|
12
|
+
from .environment import (
|
|
13
|
+
BaseUrl,
|
|
14
|
+
Endpoint,
|
|
15
|
+
Environment,
|
|
16
|
+
SenderReceiverEndpoint,
|
|
17
|
+
build_sender_receiver_url,
|
|
18
|
+
build_url,
|
|
19
|
+
)
|
|
20
|
+
from .errors import (
|
|
21
|
+
DgiiError,
|
|
22
|
+
DgiiErrorCode,
|
|
23
|
+
DgiiErrorContext,
|
|
24
|
+
extract_dgii_error_message,
|
|
25
|
+
is_retryable_status,
|
|
26
|
+
to_dgii_error,
|
|
27
|
+
)
|
|
28
|
+
from .http import (
|
|
29
|
+
HttpClient,
|
|
30
|
+
HttpClientOptions,
|
|
31
|
+
IdempotencyStore,
|
|
32
|
+
InMemoryIdempotencyStore,
|
|
33
|
+
)
|
|
34
|
+
from .resilience import ResilienceOptions, ResiliencePolicy, is_retryable_error
|
|
35
|
+
from .result import DgiiResult, Err, Ok, Result
|
|
36
|
+
from .signing import Signature, XmlCertificateValidation, validate_xml_certificate
|
|
37
|
+
from .transformer import Transformer
|
|
38
|
+
from .types import (
|
|
39
|
+
AuthToken,
|
|
40
|
+
Mensaje,
|
|
41
|
+
Operation,
|
|
42
|
+
StatusOperation,
|
|
43
|
+
TrackStatus,
|
|
44
|
+
)
|
|
45
|
+
from .utils import (
|
|
46
|
+
convert_ecf32_to_rfce,
|
|
47
|
+
generate_ecf_qr_code_url,
|
|
48
|
+
generate_fc_qr_code_url,
|
|
49
|
+
get_code_six_digit_from_signature,
|
|
50
|
+
get_current_formatted_date,
|
|
51
|
+
get_current_formatted_datetime,
|
|
52
|
+
get_xml_from_body_response,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__version__ = "0.2.1"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"ECF",
|
|
59
|
+
"AuthToken",
|
|
60
|
+
"BaseUrl",
|
|
61
|
+
"CertificateInfo",
|
|
62
|
+
"DgiiError",
|
|
63
|
+
"DgiiErrorCode",
|
|
64
|
+
"DgiiErrorContext",
|
|
65
|
+
"DgiiResult",
|
|
66
|
+
"Endpoint",
|
|
67
|
+
"Environment",
|
|
68
|
+
"Err",
|
|
69
|
+
"HttpClient",
|
|
70
|
+
"HttpClientOptions",
|
|
71
|
+
"IdempotencyStore",
|
|
72
|
+
"InMemoryIdempotencyStore",
|
|
73
|
+
"JsonObject",
|
|
74
|
+
"JsonValue",
|
|
75
|
+
"Mensaje",
|
|
76
|
+
"Ok",
|
|
77
|
+
"Operation",
|
|
78
|
+
"P12Reader",
|
|
79
|
+
"P12ReaderData",
|
|
80
|
+
"ResilienceOptions",
|
|
81
|
+
"ResiliencePolicy",
|
|
82
|
+
"Result",
|
|
83
|
+
"SenderReceiverEndpoint",
|
|
84
|
+
"Signature",
|
|
85
|
+
"StatusOperation",
|
|
86
|
+
"TrackStatus",
|
|
87
|
+
"Transformer",
|
|
88
|
+
"XmlCertificateValidation",
|
|
89
|
+
"__version__",
|
|
90
|
+
"build_sender_receiver_url",
|
|
91
|
+
"build_url",
|
|
92
|
+
"convert_ecf32_to_rfce",
|
|
93
|
+
"extract_dgii_error_message",
|
|
94
|
+
"generate_ecf_qr_code_url",
|
|
95
|
+
"generate_fc_qr_code_url",
|
|
96
|
+
"get_code_six_digit_from_signature",
|
|
97
|
+
"get_current_formatted_date",
|
|
98
|
+
"get_current_formatted_datetime",
|
|
99
|
+
"get_xml_from_body_response",
|
|
100
|
+
"is_retryable_error",
|
|
101
|
+
"is_retryable_status",
|
|
102
|
+
"to_dgii_error",
|
|
103
|
+
"validate_xml_certificate",
|
|
104
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Typing helpers shared across the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
|
|
7
|
+
#: A JSON value as returned by the DGII web services.
|
|
8
|
+
JsonValue: TypeAlias = (
|
|
9
|
+
str | int | float | bool | list["JsonValue"] | dict[str, "JsonValue"] | None
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
#: A JSON object as returned by the DGII web services.
|
|
13
|
+
JsonObject: TypeAlias = dict[str, JsonValue]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""PKCS#12 (.p12) certificate reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
|
14
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
15
|
+
|
|
16
|
+
from .errors import DgiiError, DgiiErrorCode
|
|
17
|
+
from .result import DgiiResult, Err, Ok
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class P12ReaderData:
|
|
22
|
+
"""PEM-encoded private key, certificate and public key."""
|
|
23
|
+
|
|
24
|
+
key: str | None = None
|
|
25
|
+
cert: str | None = None
|
|
26
|
+
public_key: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class CertificateInfo:
|
|
31
|
+
"""Basic metadata about an X.509 certificate."""
|
|
32
|
+
|
|
33
|
+
subject: str
|
|
34
|
+
issuer: str
|
|
35
|
+
valid_from: datetime
|
|
36
|
+
valid_to: datetime
|
|
37
|
+
serial_number: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class P12Reader:
|
|
41
|
+
"""Read a PKCS#12 bundle and extract the private key + certificate."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, passphrase: str) -> None:
|
|
44
|
+
self._passphrase = passphrase
|
|
45
|
+
|
|
46
|
+
def _load(self, data: bytes) -> tuple[PrivateKeyTypes, x509.Certificate]:
|
|
47
|
+
password = self._passphrase.encode("utf-8") if self._passphrase else None
|
|
48
|
+
key, certificate, _ = pkcs12.load_key_and_certificates(data, password)
|
|
49
|
+
if key is None or certificate is None:
|
|
50
|
+
raise ValueError("PKCS#12 must contain a private key and certificate")
|
|
51
|
+
return key, certificate
|
|
52
|
+
|
|
53
|
+
def get_key_from_file(self, file_name: str | Path) -> DgiiResult[P12ReaderData]:
|
|
54
|
+
"""Read a .p12 file from disk."""
|
|
55
|
+
try:
|
|
56
|
+
return self.get_key_from_bytes(Path(file_name).read_bytes())
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
return Err(
|
|
59
|
+
DgiiError(
|
|
60
|
+
f"Unable to read .p12 file: {file_name}",
|
|
61
|
+
code=DgiiErrorCode.CERTIFICATE,
|
|
62
|
+
cause=exc,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def get_key_from_base64(self, base64_data: str) -> DgiiResult[P12ReaderData]:
|
|
67
|
+
"""Read a base64-encoded .p12 payload."""
|
|
68
|
+
try:
|
|
69
|
+
decoded = base64.b64decode(base64_data)
|
|
70
|
+
except (ValueError, binascii.Error) as exc:
|
|
71
|
+
return Err(
|
|
72
|
+
DgiiError(
|
|
73
|
+
f"Invalid base64 .p12 payload: {exc}",
|
|
74
|
+
code=DgiiErrorCode.CERTIFICATE,
|
|
75
|
+
cause=exc,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return self.get_key_from_bytes(decoded)
|
|
79
|
+
|
|
80
|
+
def get_key_from_bytes(self, data: bytes) -> DgiiResult[P12ReaderData]:
|
|
81
|
+
"""Read a raw PKCS#12 byte string."""
|
|
82
|
+
try:
|
|
83
|
+
key, certificate = self._load(data)
|
|
84
|
+
key_pem = key.private_bytes(
|
|
85
|
+
serialization.Encoding.PEM,
|
|
86
|
+
serialization.PrivateFormat.PKCS8,
|
|
87
|
+
serialization.NoEncryption(),
|
|
88
|
+
).decode("utf-8")
|
|
89
|
+
cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode(
|
|
90
|
+
"utf-8"
|
|
91
|
+
)
|
|
92
|
+
public_key_pem = (
|
|
93
|
+
key.public_key()
|
|
94
|
+
.public_bytes(
|
|
95
|
+
serialization.Encoding.PEM,
|
|
96
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
97
|
+
)
|
|
98
|
+
.decode("utf-8")
|
|
99
|
+
)
|
|
100
|
+
return Ok(
|
|
101
|
+
P12ReaderData(
|
|
102
|
+
key=key_pem,
|
|
103
|
+
cert=cert_pem,
|
|
104
|
+
public_key=public_key_pem,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
except ValueError as exc:
|
|
108
|
+
return Err(
|
|
109
|
+
DgiiError(
|
|
110
|
+
f"Unable to parse .p12: {exc}",
|
|
111
|
+
code=DgiiErrorCode.CERTIFICATE,
|
|
112
|
+
cause=exc,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def get_certificate_info(
|
|
117
|
+
self,
|
|
118
|
+
file_name: str | Path,
|
|
119
|
+
) -> DgiiResult[CertificateInfo]:
|
|
120
|
+
"""Read certificate metadata from a .p12 file."""
|
|
121
|
+
try:
|
|
122
|
+
return self.get_certificate_info_from_bytes(Path(file_name).read_bytes())
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
return Err(
|
|
125
|
+
DgiiError(
|
|
126
|
+
f"Unable to read .p12 file: {file_name}",
|
|
127
|
+
code=DgiiErrorCode.CERTIFICATE,
|
|
128
|
+
cause=exc,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def get_certificate_info_from_base64(
|
|
133
|
+
self,
|
|
134
|
+
base64_data: str,
|
|
135
|
+
) -> DgiiResult[CertificateInfo]:
|
|
136
|
+
"""Read certificate metadata from a base64-encoded .p12 payload."""
|
|
137
|
+
try:
|
|
138
|
+
decoded = base64.b64decode(base64_data)
|
|
139
|
+
except (ValueError, binascii.Error) as exc:
|
|
140
|
+
return Err(
|
|
141
|
+
DgiiError(
|
|
142
|
+
f"Invalid base64 .p12 payload: {exc}",
|
|
143
|
+
code=DgiiErrorCode.CERTIFICATE,
|
|
144
|
+
cause=exc,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return self.get_certificate_info_from_bytes(decoded)
|
|
148
|
+
|
|
149
|
+
def get_certificate_info_from_bytes(
|
|
150
|
+
self,
|
|
151
|
+
data: bytes,
|
|
152
|
+
) -> DgiiResult[CertificateInfo]:
|
|
153
|
+
"""Read certificate metadata from raw PKCS#12 bytes."""
|
|
154
|
+
try:
|
|
155
|
+
_, certificate = self._load(data)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
return Err(
|
|
158
|
+
DgiiError(
|
|
159
|
+
f"Unable to extract certificate info: {exc}",
|
|
160
|
+
code=DgiiErrorCode.CERTIFICATE,
|
|
161
|
+
cause=exc,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return Ok(
|
|
165
|
+
CertificateInfo(
|
|
166
|
+
subject=certificate.subject.rfc4514_string(),
|
|
167
|
+
issuer=certificate.issuer.rfc4514_string(),
|
|
168
|
+
valid_from=certificate.not_valid_before_utc,
|
|
169
|
+
valid_to=certificate.not_valid_after_utc,
|
|
170
|
+
serial_number=format(certificate.serial_number, "x"),
|
|
171
|
+
)
|
|
172
|
+
)
|