ebicsclient 1.3.1__tar.gz → 1.3.3__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.
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/PKG-INFO +12 -1
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/README.md +11 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/06-engineering-conventions.md +8 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/pyproject.toml +1 -1
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/crypto.py +52 -5
- ebicsclient-1.3.3/src/ebicsclient/formats/container.py +67 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/models.py +1 -1
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/protocol/h005.py +14 -3
- ebicsclient-1.3.3/tests/test_container.py +62 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_crypto.py +21 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_h005.py +12 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_input_validation.py +4 -4
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/uv.lock +1 -1
- ebicsclient-1.3.1/src/ebicsclient/formats/container.py +0 -36
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/.github/workflows/ci.yml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/.github/workflows/release.yml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/.gitignore +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/CLAUDE.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/CONTRIBUTING.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/LICENSE.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/01-protocol-and-formats.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/02-licensing-strategy.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/03-library-landscape.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/04-implementation-plan.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/05-zkb-onboarding.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/07-handshake-testing.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/08-parity-and-xsd-findings.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/09-zkb-test-platform-settings.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/10-btf-order-types.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/11-certificate-profiles.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/examples/zkb_handshake.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/__init__.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/certificates.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/client.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/errors.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/__init__.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt052.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt053.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt054.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/pain002.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/keys.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/letter.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/protocol/__init__.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/py.typed +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/transport.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/crypto_helpers.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt052_zkb_sample.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt053_zkb_sample.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt054_collective_zkb_sample.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt054_credit_zkb_sample.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt054_debit_zkb_sample.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/htd_zkb_sample.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_accp.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_actc.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_part.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_rjct.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/hia.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/hpb.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/ini.xml +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/meta.json +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_c14n_vectors.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_camt052_054.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_camt053.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_certificates.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_client.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_errors.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_keys.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_letter.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_pain002.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_php_parity.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_schema_validation.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_transport.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/fetch-schemas.py +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/README.md +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/composer.json +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/composer.lock +0 -0
- {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/generate.php +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebicsclient
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
4
4
|
Summary: Pure-Python client for the EBICS 3.0 (H005) banking protocol
|
|
5
5
|
Project-URL: Homepage, https://github.com/schulluk/ebicsclient
|
|
6
6
|
Project-URL: Repository, https://github.com/schulluk/ebicsclient
|
|
@@ -189,6 +189,17 @@ transaction_id = client.upload(PAIN_001, pain001_bytes)
|
|
|
189
189
|
The **certificate-based ("mit Zertifikaten")** profile is a constructor option — see
|
|
190
190
|
[docs/11-certificate-profiles.md](docs/11-certificate-profiles.md).
|
|
191
191
|
|
|
192
|
+
> ### ⚠️ Loading EBICS identifiers from a config file? Quote them.
|
|
193
|
+
>
|
|
194
|
+
> EBICS IDs **can** carry leading zeros (a real Partner ID may look like `00123456`), and
|
|
195
|
+
> ISO message versions look like `"08"`. Unquoted in YAML/JSON/TOML these parse as **numbers** —
|
|
196
|
+
> the wrong type *and* silently stripped of their zeros (PyYAML even reads all-octal-digit
|
|
197
|
+
> values as octal). Since 1.3.1 the library rejects non-string values immediately with an
|
|
198
|
+
> explanatory error, but the correct fix is always **quoting the value in your config**
|
|
199
|
+
> (`partner_id: "00123456"`, `message_version: "08"`) — never wrapping the parsed number in
|
|
200
|
+
> `str()`, which would keep the wrong, zero-stripped identifier and talk to the bank as the
|
|
201
|
+
> wrong subscriber. The same applies to digits-only keyring passphrases.
|
|
202
|
+
|
|
192
203
|
## Documentation index
|
|
193
204
|
|
|
194
205
|
| Doc | Contents |
|
|
@@ -71,6 +71,17 @@ transaction_id = client.upload(PAIN_001, pain001_bytes)
|
|
|
71
71
|
The **certificate-based ("mit Zertifikaten")** profile is a constructor option — see
|
|
72
72
|
[docs/11-certificate-profiles.md](docs/11-certificate-profiles.md).
|
|
73
73
|
|
|
74
|
+
> ### ⚠️ Loading EBICS identifiers from a config file? Quote them.
|
|
75
|
+
>
|
|
76
|
+
> EBICS IDs **can** carry leading zeros (a real Partner ID may look like `00123456`), and
|
|
77
|
+
> ISO message versions look like `"08"`. Unquoted in YAML/JSON/TOML these parse as **numbers** —
|
|
78
|
+
> the wrong type *and* silently stripped of their zeros (PyYAML even reads all-octal-digit
|
|
79
|
+
> values as octal). Since 1.3.1 the library rejects non-string values immediately with an
|
|
80
|
+
> explanatory error, but the correct fix is always **quoting the value in your config**
|
|
81
|
+
> (`partner_id: "00123456"`, `message_version: "08"`) — never wrapping the parsed number in
|
|
82
|
+
> `str()`, which would keep the wrong, zero-stripped identifier and talk to the bank as the
|
|
83
|
+
> wrong subscriber. The same applies to digits-only keyring passphrases.
|
|
84
|
+
|
|
74
85
|
## Documentation index
|
|
75
86
|
|
|
76
87
|
| Doc | Contents |
|
|
@@ -91,6 +91,14 @@ specific return code on `ReturnCodeError`).
|
|
|
91
91
|
- **Gathering** those values from env vars / a vault / Django settings is the **consumer's** job, never the
|
|
92
92
|
library's. Explicit config is auditable; silent environment reads are a surprise and a leakage footgun on a
|
|
93
93
|
money-moving codebase.
|
|
94
|
+
- **Validate at the public boundary, with errors that teach the fix.** Every caller-supplied value is
|
|
95
|
+
type-checked where it enters the library (dataclass `__post_init__`, first line of a public function) —
|
|
96
|
+
never allowed to drift into `lxml`/`cryptography` and die there as a cryptic C-extension error. Learned
|
|
97
|
+
from the field: EBICS identifiers **can** carry leading zeros (`"00123456"`) and versions look like `"08"`;
|
|
98
|
+
unquoted in a consumer's YAML/JSON config they arrive as *numbers* — wrong type **and** silently
|
|
99
|
+
zero-stripped. The error message must therefore say "quote the value in your configuration", because the
|
|
100
|
+
obvious caller "fix" (`str(...)`) would keep a wrong, zero-stripped identifier and address the wrong
|
|
101
|
+
subscriber. See the README's quoting warning and `tests/test_input_validation.py`.
|
|
94
102
|
|
|
95
103
|
## Security (banking client — non-negotiable)
|
|
96
104
|
|
|
@@ -34,6 +34,16 @@ from ebicsclient.errors import CryptoError
|
|
|
34
34
|
# EBICS order-data encryption uses a 128-bit AES transaction key.
|
|
35
35
|
_TRANSACTION_KEY_BYTES = 16
|
|
36
36
|
|
|
37
|
+
# Ceiling on the inflated size of a single order-data stream. Downloaded order data is
|
|
38
|
+
# deflate-compressed by the bank, and a hostile or compromised endpoint could send a highly
|
|
39
|
+
# compressible "decompression bomb" that inflates to many gigabytes and exhausts memory
|
|
40
|
+
# (the response AuthSignature covers the transaction envelope, not the encrypted payload, so
|
|
41
|
+
# TLS is the only integrity guard on this byte stream). 512 MiB is far above any real EBICS
|
|
42
|
+
# download while still bounding the blast radius; raise it only with a concrete need.
|
|
43
|
+
_MAX_INFLATED_BYTES = 512 * 1024 * 1024
|
|
44
|
+
# Inflate in bounded steps so a single decompress() call can never allocate without limit.
|
|
45
|
+
_INFLATE_CHUNK_BYTES = 1024 * 1024
|
|
46
|
+
|
|
37
47
|
# XML-DSig algorithm identifiers used by EBICS.
|
|
38
48
|
_DS_NAMESPACE = "http://www.w3.org/2000/09/xmldsig#"
|
|
39
49
|
_INCLUSIVE_C14N = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"
|
|
@@ -149,12 +159,49 @@ def decrypt_order_data(
|
|
|
149
159
|
try:
|
|
150
160
|
decryptor = Cipher(algorithms.AES(symmetric_key), modes.CBC(_NULL_IV)).decryptor()
|
|
151
161
|
compressed = decryptor.update(encrypted_order_data) + decryptor.finalize()
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
except ValueError as error:
|
|
163
|
+
raise CryptoError("Could not decrypt the EBICS order data") from error
|
|
164
|
+
# The plaintext is a DEFLATE stream followed by block padding (not PKCS#7). Inflate it
|
|
165
|
+
# with a hard output ceiling: the decompressor stops at the end of the stream (ignoring
|
|
166
|
+
# the padding), and a decompression bomb is rejected rather than exhausting memory.
|
|
167
|
+
return _inflate_bounded(compressed, _MAX_INFLATED_BYTES)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _inflate_bounded(compressed: bytes, limit: int) -> bytes:
|
|
171
|
+
"""Inflate a DEFLATE stream, refusing to produce more than ``limit`` bytes.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
compressed: The DEFLATE-compressed bytes (trailing non-DEFLATE padding is ignored).
|
|
175
|
+
limit: The maximum number of inflated bytes to allow before failing closed.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The inflated bytes.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
CryptoError: the stream is not valid DEFLATE data, or it inflates past ``limit``.
|
|
182
|
+
"""
|
|
183
|
+
decompressor = zlib.decompressobj()
|
|
184
|
+
output = bytearray()
|
|
185
|
+
chunk = compressed
|
|
186
|
+
try:
|
|
187
|
+
while chunk and not decompressor.eof:
|
|
188
|
+
output += decompressor.decompress(chunk, _INFLATE_CHUNK_BYTES)
|
|
189
|
+
if len(output) > limit:
|
|
190
|
+
raise CryptoError(
|
|
191
|
+
f"EBICS order data inflates past the {limit}-byte safety limit — "
|
|
192
|
+
f"refusing a possible decompression bomb"
|
|
193
|
+
)
|
|
194
|
+
# unconsumed_tail holds input deferred because the output cap was hit; feed it back.
|
|
195
|
+
chunk = decompressor.unconsumed_tail
|
|
196
|
+
output += decompressor.flush()
|
|
197
|
+
except zlib.error as error:
|
|
157
198
|
raise CryptoError("Could not decrypt the EBICS order data") from error
|
|
199
|
+
if len(output) > limit:
|
|
200
|
+
raise CryptoError(
|
|
201
|
+
f"EBICS order data inflates past the {limit}-byte safety limit — "
|
|
202
|
+
f"refusing a possible decompression bomb"
|
|
203
|
+
)
|
|
204
|
+
return bytes(output)
|
|
158
205
|
|
|
159
206
|
|
|
160
207
|
def new_transaction_key() -> bytes:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Container handling shared by the message-format parsers.
|
|
2
|
+
|
|
3
|
+
EBICS download order data arrives either as a single XML document or as a ZIP container
|
|
4
|
+
holding several (one per message). Every format parser accepts both, so the splitting
|
|
5
|
+
logic lives here once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import zipfile
|
|
10
|
+
|
|
11
|
+
from ebicsclient.errors import MessageFormatError
|
|
12
|
+
|
|
13
|
+
_ZIP_MAGIC = b"PK\x03\x04"
|
|
14
|
+
|
|
15
|
+
# Bounds on a downloaded ZIP container. The bank's response AuthSignature covers the
|
|
16
|
+
# transaction envelope, not this encrypted-then-inflated payload, so a hostile or
|
|
17
|
+
# compromised endpoint could ship a "ZIP bomb" — a tiny archive that expands to gigabytes,
|
|
18
|
+
# or one with an absurd number of entries — to exhaust the client's memory. These caps are
|
|
19
|
+
# far above any real EBICS delivery (many daily statements per file) while bounding the
|
|
20
|
+
# blast radius; raise them only with a concrete need.
|
|
21
|
+
_MAX_TOTAL_UNCOMPRESSED_BYTES = 512 * 1024 * 1024
|
|
22
|
+
_MAX_ENTRIES = 10_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_documents(order_data: bytes) -> list[bytes]:
|
|
26
|
+
"""Split raw order data into its individual XML documents.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
order_data: The raw order-data bytes a download returned — a single XML document
|
|
30
|
+
or a ZIP container of them.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The contained documents; for a ZIP, in entry-name order (stable and reproducible),
|
|
34
|
+
otherwise the input as a single-element list.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
MessageFormatError: the data looks like a ZIP but cannot be read, holds more than
|
|
38
|
+
``_MAX_ENTRIES`` entries, or inflates past ``_MAX_TOTAL_UNCOMPRESSED_BYTES``
|
|
39
|
+
(a possible decompression bomb).
|
|
40
|
+
"""
|
|
41
|
+
if not order_data.startswith(_ZIP_MAGIC):
|
|
42
|
+
return [order_data]
|
|
43
|
+
try:
|
|
44
|
+
with zipfile.ZipFile(io.BytesIO(order_data)) as archive:
|
|
45
|
+
names = sorted(archive.namelist())
|
|
46
|
+
if len(names) > _MAX_ENTRIES:
|
|
47
|
+
raise MessageFormatError(
|
|
48
|
+
f"ZIP container holds {len(names)} entries, over the {_MAX_ENTRIES} "
|
|
49
|
+
f"limit — refusing a possible decompression bomb"
|
|
50
|
+
)
|
|
51
|
+
return [_read_bounded(archive, name) for name in names]
|
|
52
|
+
except (zipfile.BadZipFile, OSError) as error:
|
|
53
|
+
raise MessageFormatError("Order data is not a readable ZIP container") from error
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_bounded(archive: zipfile.ZipFile, name: str) -> bytes:
|
|
57
|
+
# Decompress lazily and stop one byte past the ceiling, so a member whose header lies
|
|
58
|
+
# about its size still cannot inflate without bound. The cap is applied per entry; a
|
|
59
|
+
# container of many just-under-cap members is still bounded by _MAX_ENTRIES.
|
|
60
|
+
with archive.open(name) as entry:
|
|
61
|
+
data = entry.read(_MAX_TOTAL_UNCOMPRESSED_BYTES + 1)
|
|
62
|
+
if len(data) > _MAX_TOTAL_UNCOMPRESSED_BYTES:
|
|
63
|
+
raise MessageFormatError(
|
|
64
|
+
f"ZIP entry {name!r} inflates past the {_MAX_TOTAL_UNCOMPRESSED_BYTES}-byte "
|
|
65
|
+
f"safety limit — refusing a possible decompression bomb"
|
|
66
|
+
)
|
|
67
|
+
return data
|
|
@@ -13,7 +13,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _require_identifier(field_name: str, value: object) -> None:
|
|
16
|
-
# EBICS identifiers
|
|
16
|
+
# EBICS identifiers can carry leading zeros (e.g. a Partner ID of "00123456").
|
|
17
17
|
# Loaded from an unquoted YAML/JSON/TOML config value they arrive as numbers — the wrong
|
|
18
18
|
# type AND silently stripped of their zeros (PyYAML even reads all-octal-digit values as
|
|
19
19
|
# octal). Failing here, with the fix in the message, beats a cryptic TypeError four
|
|
@@ -90,6 +90,10 @@ _KNOWN_RETURN_CODES = {
|
|
|
90
90
|
"091210": "EBICS_X509_WRONG_KEY_USAGE", # observed live
|
|
91
91
|
}
|
|
92
92
|
_NONCE_BYTES = 16 # 128-bit nonce, rendered as uppercase hex
|
|
93
|
+
# EBICS H005 mandates RSA keys of at least 2048 bits. Reject a bank key below that floor
|
|
94
|
+
# rather than trust it: a weak modulus delivered over a compromised HPB (the response is
|
|
95
|
+
# unsigned by design, guarded only by TLS and out-of-band hash/pinning) must not be used.
|
|
96
|
+
_MINIMUM_BANK_KEY_BITS = 2048
|
|
93
97
|
_SHA256_ALGORITHM = "http://www.w3.org/2001/04/xmlenc#sha256"
|
|
94
98
|
_PHASE_INITIALISATION = "Initialisation"
|
|
95
99
|
_PHASE_TRANSFER = "Transfer"
|
|
@@ -957,13 +961,20 @@ def _public_key_from_info(
|
|
|
957
961
|
# in case a bank sends the legacy representation.
|
|
958
962
|
certificate = info.findtext(f".//{{{_DS}}}X509Certificate")
|
|
959
963
|
if certificate is not None:
|
|
960
|
-
|
|
961
|
-
|
|
964
|
+
public_key = _public_key_from_certificate(certificate, info_tag, usage, verifier)
|
|
965
|
+
elif verifier is not None:
|
|
962
966
|
# A verifier was required but the bank sent a bare key with no certificate to check.
|
|
963
967
|
raise ProtocolError(
|
|
964
968
|
f"HPB response <{info_tag}> carries no certificate to verify"
|
|
965
969
|
)
|
|
966
|
-
|
|
970
|
+
else:
|
|
971
|
+
public_key = _public_key_from_rsa_key_value(info, info_tag)
|
|
972
|
+
if public_key.key_size < _MINIMUM_BANK_KEY_BITS:
|
|
973
|
+
raise ProtocolError(
|
|
974
|
+
f"HPB response <{info_tag}> carries a {public_key.key_size}-bit RSA key, below "
|
|
975
|
+
f"the EBICS minimum of {_MINIMUM_BANK_KEY_BITS} bits — refusing to trust it"
|
|
976
|
+
)
|
|
977
|
+
return public_key
|
|
967
978
|
|
|
968
979
|
|
|
969
980
|
def _public_key_from_certificate(
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tests for ebicsclient.formats.container: order-data splitting and bomb defences.
|
|
2
|
+
|
|
3
|
+
Download order data arrives as a single XML document or a ZIP of them. The container
|
|
4
|
+
layer both splits it and bounds decompression, because the encrypted-then-inflated payload
|
|
5
|
+
is not covered by the bank's response signature — a hostile or compromised endpoint could
|
|
6
|
+
ship a ZIP bomb, and the client must fail closed rather than exhaust memory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from ebicsclient.errors import MessageFormatError
|
|
15
|
+
from ebicsclient.formats import container
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _zip(entries: dict[str, bytes]) -> bytes:
|
|
19
|
+
buffer = io.BytesIO()
|
|
20
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
21
|
+
for name, data in entries.items():
|
|
22
|
+
archive.writestr(name, data)
|
|
23
|
+
return buffer.getvalue()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_a_single_document_is_returned_as_is() -> None:
|
|
27
|
+
document = b"<Document>not a zip</Document>"
|
|
28
|
+
assert container.extract_documents(document) == [document]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_zip_entries_are_returned_in_entry_name_order() -> None:
|
|
32
|
+
archive = _zip({"b.xml": b"<B/>", "a.xml": b"<A/>"})
|
|
33
|
+
assert container.extract_documents(archive) == [b"<A/>", b"<B/>"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_an_unreadable_zip_raises_message_format_error() -> None:
|
|
37
|
+
# Starts with the ZIP magic but is truncated garbage.
|
|
38
|
+
with pytest.raises(MessageFormatError, match="not a readable ZIP"):
|
|
39
|
+
container.extract_documents(b"PK\x03\x04broken")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_a_zip_entry_over_the_limit_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
43
|
+
# A small archive whose single entry inflates past the cap must be rejected. Lower the
|
|
44
|
+
# ceiling so the test stays fast and does not allocate hundreds of megabytes.
|
|
45
|
+
monkeypatch.setattr(container, "_MAX_TOTAL_UNCOMPRESSED_BYTES", 1024)
|
|
46
|
+
archive = _zip({"bomb.xml": b"\x00" * 4096}) # highly compressible, well over 1 KiB inflated
|
|
47
|
+
with pytest.raises(MessageFormatError, match="decompression bomb"):
|
|
48
|
+
container.extract_documents(archive)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_zip_with_too_many_entries_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
52
|
+
monkeypatch.setattr(container, "_MAX_ENTRIES", 3)
|
|
53
|
+
archive = _zip({f"{index}.xml": b"<X/>" for index in range(4)})
|
|
54
|
+
with pytest.raises(MessageFormatError, match="over the 3 limit"):
|
|
55
|
+
container.extract_documents(archive)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_zip_entry_at_the_limit_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
|
+
monkeypatch.setattr(container, "_MAX_TOTAL_UNCOMPRESSED_BYTES", 4096)
|
|
60
|
+
payload = b"A" * 4096
|
|
61
|
+
archive = _zip({"ok.xml": payload})
|
|
62
|
+
assert container.extract_documents(archive) == [payload]
|
|
@@ -137,3 +137,24 @@ def test_decrypt_order_data_round_trips(keyring: Keyring) -> None:
|
|
|
137
137
|
def test_decrypt_order_data_rejects_an_unrecoverable_transaction_key(keyring: Keyring) -> None:
|
|
138
138
|
with pytest.raises(CryptoError):
|
|
139
139
|
crypto.decrypt_order_data(keyring.encryption, b"not a valid RSA ciphertext", b"")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_decrypt_order_data_refuses_a_decompression_bomb(keyring: Keyring) -> None:
|
|
143
|
+
# A tiny, highly compressible stream that inflates past the safety ceiling must be
|
|
144
|
+
# rejected rather than exhausting memory — the bank's response signature does not cover
|
|
145
|
+
# this payload, so TLS is the only integrity guard and a hostile endpoint could send it.
|
|
146
|
+
bomb = b"\x00" * (crypto._MAX_INFLATED_BYTES + 1)
|
|
147
|
+
transaction_key, encrypted = encrypt_order_data(keyring.encryption.public_key(), bomb)
|
|
148
|
+
with pytest.raises(CryptoError, match="decompression bomb"):
|
|
149
|
+
crypto.decrypt_order_data(keyring.encryption, transaction_key, encrypted)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_inflate_bounded_allows_data_up_to_the_limit() -> None:
|
|
153
|
+
# Inflating to exactly the limit is fine; only exceeding it fails. This also exercises
|
|
154
|
+
# the multi-chunk path (the payload is far larger than one inflate chunk).
|
|
155
|
+
import zlib
|
|
156
|
+
|
|
157
|
+
payload = b"A" * (4 * 1024 * 1024)
|
|
158
|
+
assert crypto._inflate_bounded(zlib.compress(payload), len(payload)) == payload
|
|
159
|
+
with pytest.raises(CryptoError, match="decompression bomb"):
|
|
160
|
+
crypto._inflate_bounded(zlib.compress(payload), len(payload) - 1)
|
|
@@ -161,6 +161,18 @@ def test_parse_hpb_response_raises_on_a_non_ok_return_code(keyring: Keyring) ->
|
|
|
161
161
|
h005.parse_hpb_response(_ERROR_RESPONSE, keyring)
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
def test_parse_hpb_response_rejects_a_bank_key_below_the_minimum_size(keyring: Keyring) -> None:
|
|
165
|
+
# The HPB response is unsigned by design (guarded only by TLS and out-of-band hashes), so
|
|
166
|
+
# a weak modulus from a compromised endpoint must be refused, not trusted. Build a bank
|
|
167
|
+
# keyring with a 1024-bit key — Keyring checks the type, not the size, so this is possible.
|
|
168
|
+
weak = rsa.generate_private_key(public_exponent=65537, key_size=1024)
|
|
169
|
+
strong = keys.generate_keyring()
|
|
170
|
+
bank_keyring = Keyring(signature=strong.signature, authentication=weak, encryption=weak)
|
|
171
|
+
response = make_hpb_response(keyring, bank_keyring)
|
|
172
|
+
with pytest.raises(ProtocolError, match="1024-bit RSA key, below the EBICS minimum"):
|
|
173
|
+
h005.parse_hpb_response(response, keyring)
|
|
174
|
+
|
|
175
|
+
|
|
164
176
|
def test_ini_request_embeds_a_ca_issued_certificate_from_the_provider(
|
|
165
177
|
bank: Bank, user: User, keyring: Keyring
|
|
166
178
|
) -> None:
|
|
@@ -5,7 +5,7 @@ value from an unquoted YAML/JSON config entry) slipping deep into a C extension
|
|
|
5
5
|
surfacing as a cryptic error — as happened live with a numeric ``user_id`` reaching
|
|
6
6
|
``x509.NameAttribute`` (``TypeError: value argument must be a str``). Every boundary must
|
|
7
7
|
fail immediately, with a message that teaches the fix. EBICS identifiers with leading
|
|
8
|
-
zeros (``"
|
|
8
|
+
zeros (``"00123456"``) also *lose their zeros* when parsed as numbers, so the messages
|
|
9
9
|
point at quoting the config value, never at wrapping it in ``str()``.
|
|
10
10
|
"""
|
|
11
11
|
|
|
@@ -40,9 +40,9 @@ def keyring() -> Keyring:
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def test_user_rejects_a_numeric_id_with_a_teaching_message() -> None:
|
|
43
|
-
# The exact bug report: ZKB IDs like "
|
|
43
|
+
# The exact bug report: ZKB IDs like "00123456" unquoted in config arrive as ints.
|
|
44
44
|
with pytest.raises(TypeError) as caught:
|
|
45
|
-
User(partner_id="
|
|
45
|
+
User(partner_id="00123456", user_id=123456) # type: ignore[arg-type]
|
|
46
46
|
message = str(caught.value)
|
|
47
47
|
assert "user_id must be a str" in message
|
|
48
48
|
assert "int" in message
|
|
@@ -173,6 +173,6 @@ def test_the_original_bug_now_fails_at_construction_not_in_cryptography() -> Non
|
|
|
173
173
|
# Regression for the live report: with a numeric user_id the failure must happen at
|
|
174
174
|
# User(...) with our message — never reach x509.NameAttribute inside cryptography.
|
|
175
175
|
with pytest.raises(TypeError) as caught:
|
|
176
|
-
User(partner_id=
|
|
176
|
+
User(partner_id=123456, user_id=123456) # type: ignore[arg-type]
|
|
177
177
|
assert "value argument must be a str" not in str(caught.value)
|
|
178
178
|
assert "leading zeros" in str(caught.value)
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
"""Container handling shared by the message-format parsers.
|
|
2
|
-
|
|
3
|
-
EBICS download order data arrives either as a single XML document or as a ZIP container
|
|
4
|
-
holding several (one per message). Every format parser accepts both, so the splitting
|
|
5
|
-
logic lives here once.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import io
|
|
9
|
-
import zipfile
|
|
10
|
-
|
|
11
|
-
from ebicsclient.errors import MessageFormatError
|
|
12
|
-
|
|
13
|
-
_ZIP_MAGIC = b"PK\x03\x04"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def extract_documents(order_data: bytes) -> list[bytes]:
|
|
17
|
-
"""Split raw order data into its individual XML documents.
|
|
18
|
-
|
|
19
|
-
Args:
|
|
20
|
-
order_data: The raw order-data bytes a download returned — a single XML document
|
|
21
|
-
or a ZIP container of them.
|
|
22
|
-
|
|
23
|
-
Returns:
|
|
24
|
-
The contained documents; for a ZIP, in entry-name order (stable and reproducible),
|
|
25
|
-
otherwise the input as a single-element list.
|
|
26
|
-
|
|
27
|
-
Raises:
|
|
28
|
-
MessageFormatError: the data looks like a ZIP but cannot be read.
|
|
29
|
-
"""
|
|
30
|
-
if not order_data.startswith(_ZIP_MAGIC):
|
|
31
|
-
return [order_data]
|
|
32
|
-
try:
|
|
33
|
-
with zipfile.ZipFile(io.BytesIO(order_data)) as archive:
|
|
34
|
-
return [archive.read(name) for name in sorted(archive.namelist())]
|
|
35
|
-
except (zipfile.BadZipFile, OSError) as error:
|
|
36
|
-
raise MessageFormatError("Order data is not a readable ZIP container") from error
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|