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.
Files changed (78) hide show
  1. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/PKG-INFO +12 -1
  2. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/README.md +11 -0
  3. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/06-engineering-conventions.md +8 -0
  4. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/pyproject.toml +1 -1
  5. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/crypto.py +52 -5
  6. ebicsclient-1.3.3/src/ebicsclient/formats/container.py +67 -0
  7. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/models.py +1 -1
  8. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/protocol/h005.py +14 -3
  9. ebicsclient-1.3.3/tests/test_container.py +62 -0
  10. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_crypto.py +21 -0
  11. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_h005.py +12 -0
  12. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_input_validation.py +4 -4
  13. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/uv.lock +1 -1
  14. ebicsclient-1.3.1/src/ebicsclient/formats/container.py +0 -36
  15. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/.github/workflows/ci.yml +0 -0
  16. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/.github/workflows/release.yml +0 -0
  17. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/.gitignore +0 -0
  18. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/CLAUDE.md +0 -0
  19. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/CONTRIBUTING.md +0 -0
  20. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/LICENSE.md +0 -0
  21. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/01-protocol-and-formats.md +0 -0
  22. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/02-licensing-strategy.md +0 -0
  23. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/03-library-landscape.md +0 -0
  24. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/04-implementation-plan.md +0 -0
  25. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/05-zkb-onboarding.md +0 -0
  26. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/07-handshake-testing.md +0 -0
  27. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/08-parity-and-xsd-findings.md +0 -0
  28. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/09-zkb-test-platform-settings.md +0 -0
  29. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/10-btf-order-types.md +0 -0
  30. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/docs/11-certificate-profiles.md +0 -0
  31. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/examples/zkb_handshake.py +0 -0
  32. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/__init__.py +0 -0
  33. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/certificates.py +0 -0
  34. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/client.py +0 -0
  35. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/errors.py +0 -0
  36. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/__init__.py +0 -0
  37. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt.py +0 -0
  38. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt052.py +0 -0
  39. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt053.py +0 -0
  40. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt054.py +0 -0
  41. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/formats/pain002.py +0 -0
  42. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/keys.py +0 -0
  43. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/letter.py +0 -0
  44. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/protocol/__init__.py +0 -0
  45. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/py.typed +0 -0
  46. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/src/ebicsclient/transport.py +0 -0
  47. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/crypto_helpers.py +0 -0
  48. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt052_zkb_sample.xml +0 -0
  49. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt053_zkb_sample.xml +0 -0
  50. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt054_collective_zkb_sample.xml +0 -0
  51. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt054_credit_zkb_sample.xml +0 -0
  52. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/camt054_debit_zkb_sample.xml +0 -0
  53. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/htd_zkb_sample.xml +0 -0
  54. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_accp.xml +0 -0
  55. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_actc.xml +0 -0
  56. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_part.xml +0 -0
  57. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/data/pain002_rjct.xml +0 -0
  58. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/hia.xml +0 -0
  59. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/hpb.xml +0 -0
  60. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/ini.xml +0 -0
  61. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/fixtures/parity/meta.json +0 -0
  62. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_c14n_vectors.py +0 -0
  63. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_camt052_054.py +0 -0
  64. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_camt053.py +0 -0
  65. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_certificates.py +0 -0
  66. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_client.py +0 -0
  67. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_errors.py +0 -0
  68. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_keys.py +0 -0
  69. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_letter.py +0 -0
  70. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_pain002.py +0 -0
  71. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_php_parity.py +0 -0
  72. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_schema_validation.py +0 -0
  73. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tests/test_transport.py +0 -0
  74. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/fetch-schemas.py +0 -0
  75. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/README.md +0 -0
  76. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/composer.json +0 -0
  77. {ebicsclient-1.3.1 → ebicsclient-1.3.3}/tools/php-parity/composer.lock +0 -0
  78. {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.1
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ebicsclient"
7
- version = "1.3.1"
7
+ version = "1.3.3"
8
8
  description = "Pure-Python client for the EBICS 3.0 (H005) banking protocol"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
- # The plaintext is a DEFLATE stream followed by block padding (not PKCS#7). Inflate
153
- # it and let the decompressor stop at the end of the stream, ignoring the padding.
154
- decompressor = zlib.decompressobj()
155
- return decompressor.decompress(compressed) + decompressor.flush()
156
- except (ValueError, zlib.error) as error:
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 frequently carry leading zeros (e.g. a Partner ID of "00705443").
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
- return _public_key_from_certificate(certificate, info_tag, usage, verifier)
961
- if verifier is not None:
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
- return _public_key_from_rsa_key_value(info, info_tag)
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 (``"00705443"``) also *lose their zeros* when parsed as numbers, so the messages
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 "00705443" unquoted in config arrive as ints.
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="00705443", user_id=705443) # type: ignore[arg-type]
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=705443, user_id=705443) # type: ignore[arg-type]
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)
@@ -281,7 +281,7 @@ wheels = [
281
281
 
282
282
  [[package]]
283
283
  name = "ebicsclient"
284
- version = "1.3.1"
284
+ version = "1.3.3"
285
285
  source = { editable = "." }
286
286
  dependencies = [
287
287
  { name = "cryptography" },
@@ -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