ebicsclient 1.3.2__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.2 → ebicsclient-1.3.3}/PKG-INFO +1 -1
  2. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/pyproject.toml +1 -1
  3. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/crypto.py +52 -5
  4. ebicsclient-1.3.3/src/ebicsclient/formats/container.py +67 -0
  5. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/protocol/h005.py +14 -3
  6. ebicsclient-1.3.3/tests/test_container.py +62 -0
  7. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_crypto.py +21 -0
  8. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_h005.py +12 -0
  9. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/uv.lock +1 -1
  10. ebicsclient-1.3.2/src/ebicsclient/formats/container.py +0 -36
  11. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/.github/workflows/ci.yml +0 -0
  12. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/.github/workflows/release.yml +0 -0
  13. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/.gitignore +0 -0
  14. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/CLAUDE.md +0 -0
  15. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/CONTRIBUTING.md +0 -0
  16. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/LICENSE.md +0 -0
  17. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/README.md +0 -0
  18. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/01-protocol-and-formats.md +0 -0
  19. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/02-licensing-strategy.md +0 -0
  20. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/03-library-landscape.md +0 -0
  21. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/04-implementation-plan.md +0 -0
  22. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/05-zkb-onboarding.md +0 -0
  23. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/06-engineering-conventions.md +0 -0
  24. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/07-handshake-testing.md +0 -0
  25. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/08-parity-and-xsd-findings.md +0 -0
  26. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/09-zkb-test-platform-settings.md +0 -0
  27. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/10-btf-order-types.md +0 -0
  28. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/docs/11-certificate-profiles.md +0 -0
  29. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/examples/zkb_handshake.py +0 -0
  30. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/__init__.py +0 -0
  31. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/certificates.py +0 -0
  32. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/client.py +0 -0
  33. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/errors.py +0 -0
  34. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/formats/__init__.py +0 -0
  35. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt.py +0 -0
  36. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt052.py +0 -0
  37. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt053.py +0 -0
  38. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/formats/camt054.py +0 -0
  39. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/formats/pain002.py +0 -0
  40. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/keys.py +0 -0
  41. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/letter.py +0 -0
  42. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/models.py +0 -0
  43. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/protocol/__init__.py +0 -0
  44. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/py.typed +0 -0
  45. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/src/ebicsclient/transport.py +0 -0
  46. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/crypto_helpers.py +0 -0
  47. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/camt052_zkb_sample.xml +0 -0
  48. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/camt053_zkb_sample.xml +0 -0
  49. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/camt054_collective_zkb_sample.xml +0 -0
  50. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/camt054_credit_zkb_sample.xml +0 -0
  51. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/camt054_debit_zkb_sample.xml +0 -0
  52. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/htd_zkb_sample.xml +0 -0
  53. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/pain002_accp.xml +0 -0
  54. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/pain002_actc.xml +0 -0
  55. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/pain002_part.xml +0 -0
  56. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/data/pain002_rjct.xml +0 -0
  57. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/fixtures/parity/hia.xml +0 -0
  58. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/fixtures/parity/hpb.xml +0 -0
  59. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/fixtures/parity/ini.xml +0 -0
  60. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/fixtures/parity/meta.json +0 -0
  61. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_c14n_vectors.py +0 -0
  62. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_camt052_054.py +0 -0
  63. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_camt053.py +0 -0
  64. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_certificates.py +0 -0
  65. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_client.py +0 -0
  66. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_errors.py +0 -0
  67. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_input_validation.py +0 -0
  68. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_keys.py +0 -0
  69. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_letter.py +0 -0
  70. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_pain002.py +0 -0
  71. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_php_parity.py +0 -0
  72. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_schema_validation.py +0 -0
  73. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tests/test_transport.py +0 -0
  74. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tools/fetch-schemas.py +0 -0
  75. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tools/php-parity/README.md +0 -0
  76. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tools/php-parity/composer.json +0 -0
  77. {ebicsclient-1.3.2 → ebicsclient-1.3.3}/tools/php-parity/composer.lock +0 -0
  78. {ebicsclient-1.3.2 → 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.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ebicsclient"
7
- version = "1.3.2"
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
@@ -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:
@@ -281,7 +281,7 @@ wheels = [
281
281
 
282
282
  [[package]]
283
283
  name = "ebicsclient"
284
- version = "1.3.2"
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
File without changes