ebicsclient 1.3.0__tar.gz → 1.3.2__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 (76) hide show
  1. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/PKG-INFO +12 -1
  2. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/README.md +11 -0
  3. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/06-engineering-conventions.md +8 -0
  4. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/pyproject.toml +1 -1
  5. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/certificates.py +23 -2
  6. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/client.py +8 -0
  7. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/keys.py +28 -13
  8. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/letter.py +3 -0
  9. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/models.py +86 -0
  10. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/transport.py +4 -0
  11. ebicsclient-1.3.2/tests/test_input_validation.py +178 -0
  12. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/uv.lock +1 -1
  13. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/.github/workflows/ci.yml +0 -0
  14. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/.github/workflows/release.yml +0 -0
  15. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/.gitignore +0 -0
  16. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/CLAUDE.md +0 -0
  17. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/CONTRIBUTING.md +0 -0
  18. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/LICENSE.md +0 -0
  19. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/01-protocol-and-formats.md +0 -0
  20. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/02-licensing-strategy.md +0 -0
  21. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/03-library-landscape.md +0 -0
  22. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/04-implementation-plan.md +0 -0
  23. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/05-zkb-onboarding.md +0 -0
  24. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/07-handshake-testing.md +0 -0
  25. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/08-parity-and-xsd-findings.md +0 -0
  26. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/09-zkb-test-platform-settings.md +0 -0
  27. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/10-btf-order-types.md +0 -0
  28. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/11-certificate-profiles.md +0 -0
  29. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/examples/zkb_handshake.py +0 -0
  30. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/__init__.py +0 -0
  31. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/crypto.py +0 -0
  32. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/errors.py +0 -0
  33. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/__init__.py +0 -0
  34. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt.py +0 -0
  35. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt052.py +0 -0
  36. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt053.py +0 -0
  37. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt054.py +0 -0
  38. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/container.py +0 -0
  39. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/pain002.py +0 -0
  40. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/protocol/__init__.py +0 -0
  41. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/protocol/h005.py +0 -0
  42. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/py.typed +0 -0
  43. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/crypto_helpers.py +0 -0
  44. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt052_zkb_sample.xml +0 -0
  45. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt053_zkb_sample.xml +0 -0
  46. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt054_collective_zkb_sample.xml +0 -0
  47. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt054_credit_zkb_sample.xml +0 -0
  48. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt054_debit_zkb_sample.xml +0 -0
  49. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/htd_zkb_sample.xml +0 -0
  50. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_accp.xml +0 -0
  51. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_actc.xml +0 -0
  52. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_part.xml +0 -0
  53. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_rjct.xml +0 -0
  54. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/hia.xml +0 -0
  55. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/hpb.xml +0 -0
  56. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/ini.xml +0 -0
  57. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/meta.json +0 -0
  58. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_c14n_vectors.py +0 -0
  59. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_camt052_054.py +0 -0
  60. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_camt053.py +0 -0
  61. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_certificates.py +0 -0
  62. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_client.py +0 -0
  63. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_crypto.py +0 -0
  64. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_errors.py +0 -0
  65. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_h005.py +0 -0
  66. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_keys.py +0 -0
  67. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_letter.py +0 -0
  68. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_pain002.py +0 -0
  69. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_php_parity.py +0 -0
  70. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_schema_validation.py +0 -0
  71. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_transport.py +0 -0
  72. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/fetch-schemas.py +0 -0
  73. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/php-parity/README.md +0 -0
  74. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/php-parity/composer.json +0 -0
  75. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/php-parity/composer.lock +0 -0
  76. {ebicsclient-1.3.0 → ebicsclient-1.3.2}/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.0
3
+ Version: 1.3.2
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.0"
7
+ version = "1.3.2"
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"
@@ -91,8 +91,18 @@ class MappingCertificateProvider:
91
91
  Args:
92
92
  certificates: A mapping from each :class:`~ebicsclient.keys.CertificateUsage` to
93
93
  the CA-issued certificate certifying that key.
94
+
95
+ Raises:
96
+ TypeError: a mapping value is not an ``x509.Certificate``.
94
97
  """
95
98
  self._certificates = dict(certificates)
99
+ for usage, certificate in self._certificates.items():
100
+ if not isinstance(certificate, x509.Certificate):
101
+ raise TypeError(
102
+ f"The {usage} certificate must be an x509.Certificate, got "
103
+ f"{type(certificate).__name__} — load PEM/DER bytes with "
104
+ f"load_certificate()"
105
+ )
96
106
 
97
107
  def certificate(
98
108
  self, usage: CertificateUsage, private_key: rsa.RSAPrivateKey, subject_name: str
@@ -152,6 +162,12 @@ class TrustAnchorVerifier:
152
162
  self._anchors = list(trust_anchors)
153
163
  if not self._anchors:
154
164
  raise BankCertificateError("At least one trust anchor is required")
165
+ for anchor in self._anchors:
166
+ if not isinstance(anchor, x509.Certificate):
167
+ raise TypeError(
168
+ f"Trust anchors must be x509.Certificate objects, got "
169
+ f"{type(anchor).__name__} — load PEM/DER bytes with load_certificate()"
170
+ )
155
171
 
156
172
  def verify(self, certificate: x509.Certificate, usage: CertificateUsage) -> None:
157
173
  """Check the bank certificate's validity period and issuer against the anchors."""
@@ -176,11 +192,16 @@ def load_certificate(data: bytes) -> x509.Certificate:
176
192
  The parsed certificate.
177
193
 
178
194
  Raises:
179
- CertificateError: the bytes are not a readable X.509 certificate.
195
+ CertificateError: the data is not bytes or not a readable X.509 certificate.
180
196
  """
197
+ if not isinstance(data, bytes | bytearray):
198
+ raise CertificateError(
199
+ f"Certificate data must be bytes, got {type(data).__name__} — read the "
200
+ f"certificate file in binary mode ('rb')"
201
+ )
181
202
  for loader in (x509.load_pem_x509_certificate, x509.load_der_x509_certificate):
182
203
  try:
183
- return loader(data)
204
+ return loader(bytes(data))
184
205
  except ValueError:
185
206
  continue
186
207
  raise CertificateError("Data is not a readable PEM or DER X.509 certificate")
@@ -465,6 +465,14 @@ class Client:
465
465
  ReturnCodeError: the bank rejected the upload (e.g. a bad signature or order data).
466
466
  CryptoError: the order data could not be signed or encrypted.
467
467
  """
468
+ if not isinstance(order_data, bytes | bytearray):
469
+ # A str here would fail deep inside compression with an opaque error; and
470
+ # implicitly encoding it could silently alter the document. The caller owns
471
+ # the encoding: read the file in binary mode or encode it explicitly.
472
+ raise TypeError(
473
+ f"order_data must be bytes, got {type(order_data).__name__} — read the "
474
+ f"file in binary mode ('rb') or use .encode() on an XML string"
475
+ )
468
476
  bank_keys = self._require_bank_keys("Upload")
469
477
  logger.info("Upload: opening a %s/%s transaction", btf.service_name, btf.message_name)
470
478
  payload = h005.prepare_upload(self._user, self._keyring, bank_keys, order_data)
@@ -43,6 +43,18 @@ _KEY_FIELDS = ("signature", "authentication", "encryption")
43
43
  _CERTIFICATE_VALIDITY_DAYS = 365 * 10
44
44
 
45
45
 
46
+ def _require_passphrase(passphrase: object) -> None:
47
+ # A non-str passphrase (e.g. a numeric value from an unquoted config entry) would
48
+ # surface as an AttributeError deep in the serialisation layer; fail here instead.
49
+ if not isinstance(passphrase, str):
50
+ raise KeyringError(
51
+ f"passphrase must be a str, got {type(passphrase).__name__} — quote it in "
52
+ f"your configuration"
53
+ )
54
+ if not passphrase:
55
+ raise KeyringError("A non-empty passphrase is required to encrypt the keyring")
56
+
57
+
46
58
  def generate_keyring(key_size: int = _MINIMUM_KEY_SIZE) -> Keyring:
47
59
  """Generate a fresh set of the three EBICS RSA key pairs.
48
60
 
@@ -182,8 +194,7 @@ def serialize_keyring(keyring: Keyring, passphrase: str) -> bytes:
182
194
  Raises:
183
195
  KeyringError: the passphrase is empty.
184
196
  """
185
- if not passphrase:
186
- raise KeyringError("A non-empty passphrase is required to encrypt the keyring")
197
+ _require_passphrase(passphrase)
187
198
  encryption = serialization.BestAvailableEncryption(passphrase.encode("utf-8"))
188
199
  encoded = {
189
200
  field: _private_key_to_pem(getattr(keyring, field), encryption) for field in _KEY_FIELDS
@@ -206,6 +217,7 @@ def deserialize_keyring(data: bytes, passphrase: str) -> Keyring:
206
217
  KeyringError: the data is malformed, of an unknown version, or the passphrase
207
218
  is wrong.
208
219
  """
220
+ _require_passphrase(passphrase)
209
221
  try:
210
222
  envelope = json.loads(data)
211
223
  except (json.JSONDecodeError, UnicodeDecodeError) as error:
@@ -224,30 +236,32 @@ def deserialize_keyring(data: bytes, passphrase: str) -> Keyring:
224
236
  return Keyring(**keys)
225
237
 
226
238
 
227
- def save_keyring(keyring: Keyring, path: Path, passphrase: str) -> None:
239
+ def save_keyring(keyring: Keyring, path: str | Path, passphrase: str) -> None:
228
240
  """Write an encrypted keyring to a file (convenience over :func:`serialize_keyring`).
229
241
 
230
242
  Args:
231
243
  keyring: The key pairs to persist.
232
- path: Destination file path; overwritten if it already exists.
244
+ path: Destination file path (a ``str`` or ``Path``); overwritten if it exists.
233
245
  passphrase: Secret used to encrypt the private keys. Never stored or logged.
234
246
 
235
247
  Raises:
236
- KeyringError: the passphrase is empty, or the file could not be written.
248
+ KeyringError: the passphrase is empty or not a string, or the file could not be
249
+ written.
237
250
  """
238
251
  data = serialize_keyring(keyring, passphrase)
252
+ destination = Path(path)
239
253
  try:
240
- path.write_bytes(data)
254
+ destination.write_bytes(data)
241
255
  except OSError as error:
242
- raise KeyringError(f"Could not write keyring to {path}: {error}") from error
243
- logger.info("Wrote encrypted keyring to %s", path)
256
+ raise KeyringError(f"Could not write keyring to {destination}: {error}") from error
257
+ logger.info("Wrote encrypted keyring to %s", destination)
244
258
 
245
259
 
246
- def load_keyring(path: Path, passphrase: str) -> Keyring:
260
+ def load_keyring(path: str | Path, passphrase: str) -> Keyring:
247
261
  """Read and decrypt a keyring file (convenience over :func:`deserialize_keyring`).
248
262
 
249
263
  Args:
250
- path: Path to the encrypted keyring file.
264
+ path: Path to the encrypted keyring file (a ``str`` or ``Path``).
251
265
  passphrase: Secret used when the keyring was saved.
252
266
 
253
267
  Returns:
@@ -255,12 +269,13 @@ def load_keyring(path: Path, passphrase: str) -> Keyring:
255
269
 
256
270
  Raises:
257
271
  KeyringError: the file is missing, malformed, of an unknown version, or the
258
- passphrase is wrong.
272
+ passphrase is wrong or not a string.
259
273
  """
274
+ source = Path(path)
260
275
  try:
261
- data = path.read_bytes()
276
+ data = source.read_bytes()
262
277
  except OSError as error:
263
- raise KeyringError(f"Could not read keyring from {path}: {error}") from error
278
+ raise KeyringError(f"Could not read keyring from {source}: {error}") from error
264
279
  return deserialize_keyring(data, passphrase)
265
280
 
266
281
 
@@ -68,7 +68,10 @@ def make_ini_letter(
68
68
 
69
69
  Raises:
70
70
  MissingDependencyError: PDF output was requested but the ``pdf`` extra is absent.
71
+ TypeError: ``branding`` is not a ``str``.
71
72
  """
73
+ if not isinstance(branding, str):
74
+ raise TypeError(f"branding must be a str, got {type(branding).__name__}")
72
75
  resolved = _resolve_format(output_format)
73
76
  when = created if created is not None else datetime.date.today()
74
77
  panels = _key_panels(keyring)
@@ -12,6 +12,27 @@ from enum import StrEnum
12
12
  from cryptography.hazmat.primitives.asymmetric import rsa
13
13
 
14
14
 
15
+ def _require_identifier(field_name: str, value: object) -> None:
16
+ # EBICS identifiers can carry leading zeros (e.g. a Partner ID of "00123456").
17
+ # Loaded from an unquoted YAML/JSON/TOML config value they arrive as numbers — the wrong
18
+ # type AND silently stripped of their zeros (PyYAML even reads all-octal-digit values as
19
+ # octal). Failing here, with the fix in the message, beats a cryptic TypeError four
20
+ # frames deep in a crypto or XML library.
21
+ if not isinstance(value, str):
22
+ raise TypeError(
23
+ f"{field_name} must be a str, got {type(value).__name__} ({value!r}). EBICS "
24
+ f"identifiers can carry leading zeros — quote them in your configuration to "
25
+ f"keep both the type and the zeros."
26
+ )
27
+ if not value.strip():
28
+ raise ValueError(f"{field_name} must not be empty")
29
+
30
+
31
+ def _require_optional_identifier(field_name: str, value: object) -> None:
32
+ if value is not None:
33
+ _require_identifier(field_name, value)
34
+
35
+
15
36
  @dataclass(frozen=True, slots=True)
16
37
  class Bank:
17
38
  """Connection details for a bank's EBICS endpoint.
@@ -19,11 +40,19 @@ class Bank:
19
40
  Attributes:
20
41
  host_id: The bank's EBICS Host ID.
21
42
  url: The bank's EBICS HTTPS endpoint.
43
+
44
+ Raises:
45
+ TypeError: a field is not a ``str`` (e.g. a numeric config value).
46
+ ValueError: a field is empty.
22
47
  """
23
48
 
24
49
  host_id: str
25
50
  url: str
26
51
 
52
+ def __post_init__(self) -> None:
53
+ _require_identifier("host_id", self.host_id)
54
+ _require_identifier("url", self.url)
55
+
27
56
 
28
57
  @dataclass(frozen=True, slots=True)
29
58
  class User:
@@ -32,11 +61,20 @@ class User:
32
61
  Attributes:
33
62
  partner_id: The customer (Partner) ID.
34
63
  user_id: The subscriber (User) ID.
64
+
65
+ Raises:
66
+ TypeError: an ID is not a ``str`` — commonly an unquoted, zero-leading ID in a
67
+ YAML/JSON config parsed as a number (which also silently drops the zeros).
68
+ ValueError: an ID is empty.
35
69
  """
36
70
 
37
71
  partner_id: str
38
72
  user_id: str
39
73
 
74
+ def __post_init__(self) -> None:
75
+ _require_identifier("partner_id", self.partner_id)
76
+ _require_identifier("user_id", self.user_id)
77
+
40
78
 
41
79
  @dataclass(frozen=True, slots=True)
42
80
  class Keyring:
@@ -56,6 +94,18 @@ class Keyring:
56
94
  authentication: rsa.RSAPrivateKey
57
95
  encryption: rsa.RSAPrivateKey
58
96
 
97
+ def __post_init__(self) -> None:
98
+ for field_name, value in (
99
+ ("signature", self.signature),
100
+ ("authentication", self.authentication),
101
+ ("encryption", self.encryption),
102
+ ):
103
+ if not isinstance(value, rsa.RSAPrivateKey):
104
+ raise TypeError(
105
+ f"{field_name} must be an RSA private key, got {type(value).__name__} — "
106
+ f"generate keys with generate_keyring() or load them with load_keyring()"
107
+ )
108
+
59
109
 
60
110
  @dataclass(frozen=True, slots=True)
61
111
  class BankKeys:
@@ -73,6 +123,16 @@ class BankKeys:
73
123
  authentication: rsa.RSAPublicKey
74
124
  encryption: rsa.RSAPublicKey
75
125
 
126
+ def __post_init__(self) -> None:
127
+ for field_name, value in (
128
+ ("authentication", self.authentication),
129
+ ("encryption", self.encryption),
130
+ ):
131
+ if not isinstance(value, rsa.RSAPublicKey):
132
+ raise TypeError(
133
+ f"{field_name} must be an RSA public key, got {type(value).__name__}"
134
+ )
135
+
76
136
 
77
137
  @dataclass(frozen=True, slots=True)
78
138
  class BankKeyHashes:
@@ -90,6 +150,22 @@ class BankKeyHashes:
90
150
  authentication: bytes
91
151
  encryption: bytes
92
152
 
153
+ def __post_init__(self) -> None:
154
+ for field_name, value in (
155
+ ("authentication", self.authentication),
156
+ ("encryption", self.encryption),
157
+ ):
158
+ if not isinstance(value, bytes):
159
+ raise TypeError(
160
+ f"{field_name} must be the raw 32-byte SHA-256 digest as bytes, got "
161
+ f"{type(value).__name__} — for a published hex hash use "
162
+ f'bytes.fromhex(hash_text.replace(" ", ""))'
163
+ )
164
+ if len(value) != 32:
165
+ raise ValueError(
166
+ f"{field_name} must be exactly 32 bytes (SHA-256), got {len(value)}"
167
+ )
168
+
93
169
 
94
170
  class InitializationState(StrEnum):
95
171
  """The outcome of submitting subscriber keys with INI or HIA.
@@ -127,6 +203,16 @@ class BusinessTransactionFormat:
127
203
  container: str | None = None
128
204
  service_option: str | None = None
129
205
 
206
+ def __post_init__(self) -> None:
207
+ # message_version is the classic trap: an unquoted "08" in YAML/JSON config arrives
208
+ # as the int 8 — wrong type and a silently different version on the wire.
209
+ _require_identifier("service_name", self.service_name)
210
+ _require_identifier("message_name", self.message_name)
211
+ _require_optional_identifier("scope", self.scope)
212
+ _require_optional_identifier("message_version", self.message_version)
213
+ _require_optional_identifier("container", self.container)
214
+ _require_optional_identifier("service_option", self.service_option)
215
+
130
216
 
131
217
  #: The Swiss camt.053.001.08 account-statement download (end-of-period, ZIP container).
132
218
  #: Matches the ZKB ``Z53`` order type verbatim (``EOP / CH / camt.053 / 08 / ZIP``); see
@@ -49,6 +49,10 @@ class Transport:
49
49
  Raises:
50
50
  TransportError: the URL is not an HTTPS URL.
51
51
  """
52
+ if not isinstance(url, str):
53
+ raise TransportError(
54
+ f"EBICS endpoint URL must be a str, got {type(url).__name__} ({url!r})"
55
+ )
52
56
  if not url.lower().startswith("https://"):
53
57
  raise TransportError(f"EBICS endpoint must be an https:// URL, got {url!r}")
54
58
  self._url = url
@@ -0,0 +1,178 @@
1
+ """Tests for input validation at the library's public boundaries.
2
+
3
+ The bug class under test: a caller-supplied value of the wrong type (typically a numeric
4
+ value from an unquoted YAML/JSON config entry) slipping deep into a C extension and
5
+ surfacing as a cryptic error — as happened live with a numeric ``user_id`` reaching
6
+ ``x509.NameAttribute`` (``TypeError: value argument must be a str``). Every boundary must
7
+ fail immediately, with a message that teaches the fix. EBICS identifiers with leading
8
+ zeros (``"00123456"``) also *lose their zeros* when parsed as numbers, so the messages
9
+ point at quoting the config value, never at wrapping it in ``str()``.
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from ebicsclient import (
17
+ Bank,
18
+ BankKeyHashes,
19
+ BankKeys,
20
+ BusinessTransactionFormat,
21
+ Client,
22
+ Keyring,
23
+ MappingCertificateProvider,
24
+ TrustAnchorVerifier,
25
+ User,
26
+ generate_keyring,
27
+ load_certificate,
28
+ load_keyring,
29
+ save_keyring,
30
+ )
31
+ from ebicsclient.errors import CertificateError, KeyringError, TransportError
32
+ from ebicsclient.keys import CertificateUsage
33
+ from ebicsclient.models import PAIN_001
34
+ from ebicsclient.transport import Transport
35
+
36
+
37
+ @pytest.fixture(scope="module")
38
+ def keyring() -> Keyring:
39
+ return generate_keyring()
40
+
41
+
42
+ def test_user_rejects_a_numeric_id_with_a_teaching_message() -> None:
43
+ # The exact bug report: ZKB IDs like "00123456" unquoted in config arrive as ints.
44
+ with pytest.raises(TypeError) as caught:
45
+ User(partner_id="00123456", user_id=123456) # type: ignore[arg-type]
46
+ message = str(caught.value)
47
+ assert "user_id must be a str" in message
48
+ assert "int" in message
49
+ assert "quote" in message # the fix, not just the failure
50
+
51
+
52
+ def test_user_rejects_an_empty_id() -> None:
53
+ with pytest.raises(ValueError):
54
+ User(partner_id="PARTNER1", user_id=" ")
55
+
56
+
57
+ def test_bank_rejects_a_non_string_host_id() -> None:
58
+ with pytest.raises(TypeError) as caught:
59
+ Bank(host_id=12345, url="https://example.com/ebicsweb") # type: ignore[arg-type]
60
+ assert "host_id must be a str" in str(caught.value)
61
+
62
+
63
+ def test_business_transaction_format_rejects_a_numeric_message_version() -> None:
64
+ # The classic YAML trap: an unquoted "08" arrives as the int 8 — wrong type AND a
65
+ # silently different version.
66
+ with pytest.raises(TypeError) as caught:
67
+ BusinessTransactionFormat(
68
+ service_name="EOP",
69
+ message_name="camt.053",
70
+ message_version=8, # type: ignore[arg-type]
71
+ )
72
+ assert "message_version must be a str" in str(caught.value)
73
+
74
+
75
+ def test_keyring_rejects_non_key_values() -> None:
76
+ with pytest.raises(TypeError) as caught:
77
+ Keyring(signature="not-a-key", authentication="x", encryption="y") # type: ignore[arg-type]
78
+ assert "signature must be an RSA private key" in str(caught.value)
79
+
80
+
81
+ def test_bank_keys_reject_private_keys() -> None:
82
+ keys = generate_keyring()
83
+ with pytest.raises(TypeError):
84
+ BankKeys(authentication=keys.authentication, encryption=keys.encryption) # type: ignore[arg-type]
85
+
86
+
87
+ def test_bank_key_hashes_reject_a_hex_string() -> None:
88
+ digest = b"\x00" * 32
89
+ with pytest.raises(TypeError) as caught:
90
+ BankKeyHashes(authentication="18 72 B2 39", encryption=digest) # type: ignore[arg-type]
91
+ assert "bytes.fromhex" in str(caught.value) # the message teaches the conversion
92
+
93
+
94
+ def test_bank_key_hashes_reject_a_wrong_length() -> None:
95
+ with pytest.raises(ValueError):
96
+ BankKeyHashes(authentication=b"\x00" * 32, encryption=b"\x00" * 20)
97
+
98
+
99
+ def test_save_and_load_keyring_accept_a_str_path(tmp_path: Path, keyring: Keyring) -> None:
100
+ # The README quickstart passes a plain string path — it must work.
101
+ path = str(tmp_path / "keyring.json")
102
+ save_keyring(keyring, path, "passphrase")
103
+ loaded = load_keyring(path, "passphrase")
104
+ assert loaded.signature.public_key().public_numbers() == (
105
+ keyring.signature.public_key().public_numbers()
106
+ )
107
+
108
+
109
+ def test_serialize_keyring_rejects_a_non_string_passphrase(
110
+ tmp_path: Path, keyring: Keyring
111
+ ) -> None:
112
+ with pytest.raises(KeyringError) as caught:
113
+ save_keyring(keyring, tmp_path / "keyring.json", 123456) # type: ignore[arg-type]
114
+ assert "passphrase must be a str" in str(caught.value)
115
+
116
+
117
+ def test_load_keyring_rejects_a_non_string_passphrase(tmp_path: Path, keyring: Keyring) -> None:
118
+ path = tmp_path / "keyring.json"
119
+ save_keyring(keyring, path, "passphrase")
120
+ with pytest.raises(KeyringError):
121
+ load_keyring(path, 123456) # type: ignore[arg-type]
122
+
123
+
124
+ def test_upload_rejects_a_string_document(keyring: Keyring) -> None:
125
+ client = Client(
126
+ Bank(host_id="HOST", url="https://example.com/ebicsweb"),
127
+ User(partner_id="PARTNER1", user_id="USER1"),
128
+ keyring,
129
+ )
130
+ with pytest.raises(TypeError) as caught:
131
+ client.upload(PAIN_001, "<Document/>") # type: ignore[arg-type]
132
+ message = str(caught.value)
133
+ assert "order_data must be bytes" in message
134
+ assert "'rb'" in message # the fix is in the message
135
+
136
+
137
+ def test_transport_rejects_a_non_string_url() -> None:
138
+ with pytest.raises(TransportError) as caught:
139
+ Transport(12345) # type: ignore[arg-type]
140
+ assert "must be a str" in str(caught.value)
141
+
142
+
143
+ def test_make_ini_letter_rejects_a_non_string_branding(keyring: Keyring) -> None:
144
+ client = Client(
145
+ Bank(host_id="HOST", url="https://example.com/ebicsweb"),
146
+ User(partner_id="PARTNER1", user_id="USER1"),
147
+ keyring,
148
+ )
149
+ with pytest.raises(TypeError) as caught:
150
+ client.make_ini_letter(branding=42) # type: ignore[arg-type]
151
+ assert "branding must be a str" in str(caught.value)
152
+
153
+
154
+ def test_load_certificate_rejects_a_pem_string() -> None:
155
+ with pytest.raises(CertificateError) as caught:
156
+ load_certificate("-----BEGIN CERTIFICATE-----") # type: ignore[arg-type]
157
+ assert "'rb'" in str(caught.value)
158
+
159
+
160
+ def test_trust_anchor_verifier_rejects_non_certificate_anchors() -> None:
161
+ with pytest.raises(TypeError) as caught:
162
+ TrustAnchorVerifier(["not-a-certificate"]) # type: ignore[list-item]
163
+ assert "load_certificate()" in str(caught.value)
164
+
165
+
166
+ def test_mapping_certificate_provider_rejects_non_certificate_values() -> None:
167
+ with pytest.raises(TypeError) as caught:
168
+ MappingCertificateProvider({CertificateUsage.SIGNATURE: b"DER bytes"}) # type: ignore[dict-item]
169
+ assert "load_certificate()" in str(caught.value)
170
+
171
+
172
+ def test_the_original_bug_now_fails_at_construction_not_in_cryptography() -> None:
173
+ # Regression for the live report: with a numeric user_id the failure must happen at
174
+ # User(...) with our message — never reach x509.NameAttribute inside cryptography.
175
+ with pytest.raises(TypeError) as caught:
176
+ User(partner_id=123456, user_id=123456) # type: ignore[arg-type]
177
+ assert "value argument must be a str" not in str(caught.value)
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.0"
284
+ version = "1.3.2"
285
285
  source = { editable = "." }
286
286
  dependencies = [
287
287
  { name = "cryptography" },
File without changes
File without changes
File without changes
File without changes