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.
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/PKG-INFO +12 -1
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/README.md +11 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/06-engineering-conventions.md +8 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/pyproject.toml +1 -1
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/certificates.py +23 -2
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/client.py +8 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/keys.py +28 -13
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/letter.py +3 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/models.py +86 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/transport.py +4 -0
- ebicsclient-1.3.2/tests/test_input_validation.py +178 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/uv.lock +1 -1
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/.github/workflows/ci.yml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/.github/workflows/release.yml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/.gitignore +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/CLAUDE.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/CONTRIBUTING.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/LICENSE.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/01-protocol-and-formats.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/02-licensing-strategy.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/03-library-landscape.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/04-implementation-plan.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/05-zkb-onboarding.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/07-handshake-testing.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/08-parity-and-xsd-findings.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/09-zkb-test-platform-settings.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/10-btf-order-types.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/docs/11-certificate-profiles.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/examples/zkb_handshake.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/__init__.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/crypto.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/errors.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/__init__.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt052.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt053.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/camt054.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/container.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/formats/pain002.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/protocol/__init__.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/protocol/h005.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/src/ebicsclient/py.typed +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/crypto_helpers.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt052_zkb_sample.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt053_zkb_sample.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt054_collective_zkb_sample.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt054_credit_zkb_sample.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/camt054_debit_zkb_sample.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/htd_zkb_sample.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_accp.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_actc.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_part.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/data/pain002_rjct.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/hia.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/hpb.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/ini.xml +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/fixtures/parity/meta.json +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_c14n_vectors.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_camt052_054.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_camt053.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_certificates.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_client.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_crypto.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_errors.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_h005.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_keys.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_letter.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_pain002.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_php_parity.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_schema_validation.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tests/test_transport.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/fetch-schemas.py +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/php-parity/README.md +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/php-parity/composer.json +0 -0
- {ebicsclient-1.3.0 → ebicsclient-1.3.2}/tools/php-parity/composer.lock +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
254
|
+
destination.write_bytes(data)
|
|
241
255
|
except OSError as error:
|
|
242
|
-
raise KeyringError(f"Could not write keyring to {
|
|
243
|
-
logger.info("Wrote encrypted keyring to %s",
|
|
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 =
|
|
276
|
+
data = source.read_bytes()
|
|
262
277
|
except OSError as error:
|
|
263
|
-
raise KeyringError(f"Could not read keyring from {
|
|
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)
|
|
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
|