ebicsclient 0.2.0__tar.gz → 1.3.1__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-0.2.0 → ebicsclient-1.3.1}/.github/workflows/ci.yml +2 -2
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/.github/workflows/release.yml +4 -4
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/CLAUDE.md +6 -3
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/PKG-INFO +45 -9
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/README.md +43 -7
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/examples/zkb_handshake.py +34 -1
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/pyproject.toml +2 -2
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/__init__.py +6 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/certificates.py +23 -2
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/client.py +96 -12
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/errors.py +9 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/keys.py +28 -13
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/letter.py +3 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/models.py +130 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/protocol/h005.py +188 -3
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/transport.py +4 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/crypto_helpers.py +21 -4
- ebicsclient-1.3.1/tests/data/htd_zkb_sample.xml +707 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_client.py +121 -32
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_h005.py +109 -1
- ebicsclient-1.3.1/tests/test_input_validation.py +178 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_schema_validation.py +10 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/uv.lock +1 -1
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/.gitignore +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/CONTRIBUTING.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/LICENSE.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/01-protocol-and-formats.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/02-licensing-strategy.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/03-library-landscape.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/04-implementation-plan.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/05-zkb-onboarding.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/06-engineering-conventions.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/07-handshake-testing.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/08-parity-and-xsd-findings.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/09-zkb-test-platform-settings.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/10-btf-order-types.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/docs/11-certificate-profiles.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/crypto.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/__init__.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/camt.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/camt052.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/camt053.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/camt054.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/container.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/formats/pain002.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/protocol/__init__.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/src/ebicsclient/py.typed +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/camt052_zkb_sample.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/camt053_zkb_sample.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/camt054_collective_zkb_sample.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/camt054_credit_zkb_sample.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/camt054_debit_zkb_sample.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/pain002_accp.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/pain002_actc.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/pain002_part.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/data/pain002_rjct.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/fixtures/parity/hia.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/fixtures/parity/hpb.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/fixtures/parity/ini.xml +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/fixtures/parity/meta.json +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_c14n_vectors.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_camt052_054.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_camt053.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_certificates.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_crypto.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_errors.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_keys.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_letter.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_pain002.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_php_parity.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tests/test_transport.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tools/fetch-schemas.py +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tools/php-parity/README.md +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tools/php-parity/composer.json +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tools/php-parity/composer.lock +0 -0
- {ebicsclient-0.2.0 → ebicsclient-1.3.1}/tools/php-parity/generate.php +0 -0
|
@@ -13,10 +13,10 @@ jobs:
|
|
|
13
13
|
matrix:
|
|
14
14
|
python-version: ["3.11", "3.12", "3.13"]
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
17
|
|
|
18
18
|
- name: Install uv
|
|
19
|
-
uses: astral-sh/setup-uv@
|
|
19
|
+
uses: astral-sh/setup-uv@v7
|
|
20
20
|
with:
|
|
21
21
|
python-version: ${{ matrix.python-version }}
|
|
22
22
|
|
|
@@ -8,10 +8,10 @@ jobs:
|
|
|
8
8
|
build:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
10
|
steps:
|
|
11
|
-
- uses: actions/checkout@
|
|
11
|
+
- uses: actions/checkout@v6
|
|
12
12
|
|
|
13
13
|
- name: Install uv
|
|
14
|
-
uses: astral-sh/setup-uv@
|
|
14
|
+
uses: astral-sh/setup-uv@v7
|
|
15
15
|
with:
|
|
16
16
|
python-version: "3.12"
|
|
17
17
|
|
|
@@ -42,7 +42,7 @@ jobs:
|
|
|
42
42
|
- name: Build sdist and wheel
|
|
43
43
|
run: uv build
|
|
44
44
|
|
|
45
|
-
- uses: actions/upload-artifact@
|
|
45
|
+
- uses: actions/upload-artifact@v5
|
|
46
46
|
with:
|
|
47
47
|
name: dist
|
|
48
48
|
path: dist/
|
|
@@ -58,7 +58,7 @@ jobs:
|
|
|
58
58
|
# anywhere; PyPI trusts this repository + workflow + environment combination.
|
|
59
59
|
id-token: write
|
|
60
60
|
steps:
|
|
61
|
-
- uses: actions/download-artifact@
|
|
61
|
+
- uses: actions/download-artifact@v6
|
|
62
62
|
with:
|
|
63
63
|
name: dist
|
|
64
64
|
path: dist/
|
|
@@ -78,10 +78,13 @@ MVP = read **and** write, validated live against ZKB: key init (INI/HIA/HPB) →
|
|
|
78
78
|
`EOP/camt.053.001.08` and parse balances → upload `MCT/pain.001.001.09` with the A006 electronic
|
|
79
79
|
signature. All of this is built and bank-accepted.
|
|
80
80
|
|
|
81
|
+
The pain.002/camt.052/camt.054 parsers are built (M5, validated on genuine ZKB messages).
|
|
81
82
|
Still deliberately **out of scope** — do not build until there is a concrete need and a way to
|
|
82
|
-
validate it: distributed signatures (EDS
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
validate it: distributed signatures (EDS — the ZKB test subscriber has ``NumSigRequired=0``, so
|
|
84
|
+
EDS cannot be validated there) and multi-bank quirk handling. **H004 and earlier EBICS versions
|
|
85
|
+
will never be supported** (owner decision; see docs/04) — the `protocol/` seam exists for a
|
|
86
|
+
future EBICS version, not for the past. Scope creep is what turns a stable standard into a
|
|
87
|
+
maintenance burden.
|
|
85
88
|
|
|
86
89
|
## Workflow
|
|
87
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ebicsclient
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.3.1
|
|
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
|
|
@@ -95,7 +95,7 @@ License: Copyright © 2026 Lukas Schulze
|
|
|
95
95
|
**Use** means anything you do with the software requiring one of your licenses.
|
|
96
96
|
License-File: LICENSE.md
|
|
97
97
|
Keywords: banking,camt,ebics,fintech,h005,iso20022
|
|
98
|
-
Classifier: Development Status ::
|
|
98
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
99
99
|
Classifier: Intended Audience :: Developers
|
|
100
100
|
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
101
101
|
Classifier: License :: Other/Proprietary License
|
|
@@ -118,9 +118,25 @@ Description-Content-Type: text/markdown
|
|
|
118
118
|
|
|
119
119
|
# ebicsclient — a pure-Python EBICS 3.0 (H005) client
|
|
120
120
|
|
|
121
|
-
A from-scratch, pure-Python client for the **EBICS** banking protocol.
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
A from-scratch, pure-Python client for the **EBICS** banking protocol (EBICS 3.0 / H005),
|
|
122
|
+
validated live against Zürcher Kantonalbank (ZKB).
|
|
123
|
+
|
|
124
|
+
**What it provides today:**
|
|
125
|
+
|
|
126
|
+
- **Key ceremony** — INI/HIA/HPB with X.509 key transmission (self-signed *mit Schlüsseln* or
|
|
127
|
+
CA-issued *mit Zertifikaten*), the printable initialisation letter (HTML/PDF), and bank-key
|
|
128
|
+
**pinning** across sessions
|
|
129
|
+
- **Read** — statement/report downloads with parsers into typed models: **camt.053** end-of-day
|
|
130
|
+
statements, **camt.052** intraday reports, **camt.054** booking advices (incl. QRR/SCOR/LSV),
|
|
131
|
+
and **pain.002** payment status reports
|
|
132
|
+
- **Write** — **pain.001** payment submission (BTU) with the A006 electronic signature
|
|
133
|
+
- **Security throughout** — the bank's `AuthSignature` is verified on **every** response, unknown
|
|
134
|
+
return codes are never masked, and everything fails closed
|
|
135
|
+
- **Self-inspection** — `available_order_types()` (HAA) and `subscriber_info()` (HTD)
|
|
136
|
+
|
|
137
|
+
**What is missing:** distributed signatures (EDS/VEU — multi-person payment approval; see
|
|
138
|
+
milestone 7 below), and validation against banks beyond ZKB. Legacy EBICS versions (H004 and
|
|
139
|
+
earlier) are deliberately unsupported.
|
|
124
140
|
|
|
125
141
|
- **Stack:** Python 3.11+, just two runtime deps — `cryptography` (RSA/AES) and `lxml` (XML /
|
|
126
142
|
inclusive Canonical XML 1.0); everything else stdlib. No PHP/Java sidecar. (Rationale:
|
|
@@ -238,11 +254,31 @@ a real bank statement.
|
|
|
238
254
|
- [x] camt.052 intraday reports
|
|
239
255
|
- [x] camt.054 booking advices (incl. the QRR / SCOR / LSV variants via `service_option`)
|
|
240
256
|
|
|
241
|
-
**Milestone 6 — Protocol
|
|
257
|
+
**Milestone 6 — Protocol hardening & conveniences**
|
|
258
|
+
|
|
259
|
+
- [x] Verify the bank's `AuthSignature` on every response — validated live on ZKB
|
|
260
|
+
- [x] Subscriber self-inspection — `available_order_types()` (HAA) and `subscriber_info()` (HTD)
|
|
261
|
+
|
|
262
|
+
**Milestone 7 — Distributed signatures (EDS/VEU)** (next; parked until validatable)
|
|
263
|
+
|
|
264
|
+
EDS (*Elektronische Verteilte Unterschrift*) is EBICS's workflow for orders that need
|
|
265
|
+
**multiple people to sign before the bank executes them** — dual control on payments. An order
|
|
266
|
+
is uploaded with `requestEDS` and parks in the bank's VEU queue; further signatories list the
|
|
267
|
+
queue, inspect the order, and deliver their A006 signatures until the configured quorum (e.g.
|
|
268
|
+
first + second signature) is reached. The building blocks (A006 signing, the `SignatureFlag`,
|
|
269
|
+
the admin-download transaction pattern) are in place; what is missing is a **validatable
|
|
270
|
+
multi-user setup** — the ZKB test subscriber is single-user with `NumSigRequired=0`, so the
|
|
271
|
+
workflow cannot be exercised there. This milestone starts when a downstream setup with a real
|
|
272
|
+
multi-signature profile exists.
|
|
273
|
+
|
|
274
|
+
- [ ] Upload into the VEU queue (`SignatureFlag` with `requestEDS`)
|
|
275
|
+
- [ ] List pending orders (HVU/HVZ) and fetch order details (HVD) and transactions (HVT)
|
|
276
|
+
- [ ] Deliver an additional signature (HVE) and cancel a pending order (HVS)
|
|
277
|
+
- [ ] Validate the full quorum workflow against a multi-signature bank profile
|
|
242
278
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
279
|
+
This client is **EBICS 3.0 (H005) only** — legacy versions (H004 and earlier) will not be
|
|
280
|
+
supported (see [docs/04](docs/04-implementation-plan.md)); the `protocol/` seam exists for a
|
|
281
|
+
future EBICS version, not for the past.
|
|
246
282
|
|
|
247
283
|
## License
|
|
248
284
|
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
# ebicsclient — a pure-Python EBICS 3.0 (H005) client
|
|
2
2
|
|
|
3
|
-
A from-scratch, pure-Python client for the **EBICS** banking protocol.
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A from-scratch, pure-Python client for the **EBICS** banking protocol (EBICS 3.0 / H005),
|
|
4
|
+
validated live against Zürcher Kantonalbank (ZKB).
|
|
5
|
+
|
|
6
|
+
**What it provides today:**
|
|
7
|
+
|
|
8
|
+
- **Key ceremony** — INI/HIA/HPB with X.509 key transmission (self-signed *mit Schlüsseln* or
|
|
9
|
+
CA-issued *mit Zertifikaten*), the printable initialisation letter (HTML/PDF), and bank-key
|
|
10
|
+
**pinning** across sessions
|
|
11
|
+
- **Read** — statement/report downloads with parsers into typed models: **camt.053** end-of-day
|
|
12
|
+
statements, **camt.052** intraday reports, **camt.054** booking advices (incl. QRR/SCOR/LSV),
|
|
13
|
+
and **pain.002** payment status reports
|
|
14
|
+
- **Write** — **pain.001** payment submission (BTU) with the A006 electronic signature
|
|
15
|
+
- **Security throughout** — the bank's `AuthSignature` is verified on **every** response, unknown
|
|
16
|
+
return codes are never masked, and everything fails closed
|
|
17
|
+
- **Self-inspection** — `available_order_types()` (HAA) and `subscriber_info()` (HTD)
|
|
18
|
+
|
|
19
|
+
**What is missing:** distributed signatures (EDS/VEU — multi-person payment approval; see
|
|
20
|
+
milestone 7 below), and validation against banks beyond ZKB. Legacy EBICS versions (H004 and
|
|
21
|
+
earlier) are deliberately unsupported.
|
|
6
22
|
|
|
7
23
|
- **Stack:** Python 3.11+, just two runtime deps — `cryptography` (RSA/AES) and `lxml` (XML /
|
|
8
24
|
inclusive Canonical XML 1.0); everything else stdlib. No PHP/Java sidecar. (Rationale:
|
|
@@ -120,11 +136,31 @@ a real bank statement.
|
|
|
120
136
|
- [x] camt.052 intraday reports
|
|
121
137
|
- [x] camt.054 booking advices (incl. the QRR / SCOR / LSV variants via `service_option`)
|
|
122
138
|
|
|
123
|
-
**Milestone 6 — Protocol
|
|
139
|
+
**Milestone 6 — Protocol hardening & conveniences**
|
|
124
140
|
|
|
125
|
-
- [
|
|
126
|
-
- [
|
|
127
|
-
|
|
141
|
+
- [x] Verify the bank's `AuthSignature` on every response — validated live on ZKB
|
|
142
|
+
- [x] Subscriber self-inspection — `available_order_types()` (HAA) and `subscriber_info()` (HTD)
|
|
143
|
+
|
|
144
|
+
**Milestone 7 — Distributed signatures (EDS/VEU)** (next; parked until validatable)
|
|
145
|
+
|
|
146
|
+
EDS (*Elektronische Verteilte Unterschrift*) is EBICS's workflow for orders that need
|
|
147
|
+
**multiple people to sign before the bank executes them** — dual control on payments. An order
|
|
148
|
+
is uploaded with `requestEDS` and parks in the bank's VEU queue; further signatories list the
|
|
149
|
+
queue, inspect the order, and deliver their A006 signatures until the configured quorum (e.g.
|
|
150
|
+
first + second signature) is reached. The building blocks (A006 signing, the `SignatureFlag`,
|
|
151
|
+
the admin-download transaction pattern) are in place; what is missing is a **validatable
|
|
152
|
+
multi-user setup** — the ZKB test subscriber is single-user with `NumSigRequired=0`, so the
|
|
153
|
+
workflow cannot be exercised there. This milestone starts when a downstream setup with a real
|
|
154
|
+
multi-signature profile exists.
|
|
155
|
+
|
|
156
|
+
- [ ] Upload into the VEU queue (`SignatureFlag` with `requestEDS`)
|
|
157
|
+
- [ ] List pending orders (HVU/HVZ) and fetch order details (HVD) and transactions (HVT)
|
|
158
|
+
- [ ] Deliver an additional signature (HVE) and cancel a pending order (HVS)
|
|
159
|
+
- [ ] Validate the full quorum workflow against a multi-signature bank profile
|
|
160
|
+
|
|
161
|
+
This client is **EBICS 3.0 (H005) only** — legacy versions (H004 and earlier) will not be
|
|
162
|
+
supported (see [docs/04](docs/04-implementation-plan.md)); the `protocol/` seam exists for a
|
|
163
|
+
future EBICS version, not for the past.
|
|
128
164
|
|
|
129
165
|
## License
|
|
130
166
|
|
|
@@ -15,6 +15,8 @@ Steps, matching the EBICS initialisation ceremony (see docs/05, docs/07):
|
|
|
15
15
|
download fetch the EOP/camt.053 statements and print their closing balances
|
|
16
16
|
upload upload a pain.001 payment file (path in EBICS_UPLOAD_PATH)
|
|
17
17
|
pain002 fetch the pain.002 payment status report(s) and print them
|
|
18
|
+
haa list the order types with data available at the bank
|
|
19
|
+
htd show the subscriber data registered at the bank
|
|
18
20
|
|
|
19
21
|
Run each step in order, e.g.::
|
|
20
22
|
|
|
@@ -83,7 +85,8 @@ def main() -> int:
|
|
|
83
85
|
parser.add_argument(
|
|
84
86
|
"step",
|
|
85
87
|
choices=[
|
|
86
|
-
"generate", "ini", "hia", "letter", "hpb", "hashes", "download", "upload",
|
|
88
|
+
"generate", "ini", "hia", "letter", "hpb", "hashes", "download", "upload",
|
|
89
|
+
"pain002", "haa", "htd",
|
|
87
90
|
],
|
|
88
91
|
)
|
|
89
92
|
step = parser.parse_args().step
|
|
@@ -219,6 +222,34 @@ def _pain002() -> int:
|
|
|
219
222
|
return 0
|
|
220
223
|
|
|
221
224
|
|
|
225
|
+
def _haa() -> int:
|
|
226
|
+
client = _build_client()
|
|
227
|
+
_fetch_bank_keys(client)
|
|
228
|
+
order_types = client.available_order_types()
|
|
229
|
+
if not order_types:
|
|
230
|
+
print("HAA: no order types currently have data available.")
|
|
231
|
+
return 0
|
|
232
|
+
print(f"HAA: {len(order_types)} order type(s) with data available:")
|
|
233
|
+
for btf in order_types:
|
|
234
|
+
print(f" {btf.service_name}/{btf.scope or '-'}/{btf.message_name}"
|
|
235
|
+
f" v{btf.message_version or '-'} container={btf.container or '-'}")
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _htd() -> int:
|
|
240
|
+
client = _build_client()
|
|
241
|
+
_fetch_bank_keys(client)
|
|
242
|
+
info = client.subscriber_info()
|
|
243
|
+
print(f"HTD: user {info.user_id} (status {info.user_status}), name: {info.name}")
|
|
244
|
+
print(f" permissions: {len(info.permissions)}, partner order types: {len(info.order_types)}")
|
|
245
|
+
for entry in info.order_types:
|
|
246
|
+
service = entry.service
|
|
247
|
+
btf = (f"{service.service_name}/{service.scope or '-'}/{service.message_name}"
|
|
248
|
+
f" v{service.message_version or '-'}") if service else "-"
|
|
249
|
+
print(f" {entry.admin_order_type:<4} {btf:<40} {entry.description or ''}")
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
222
253
|
def _build_client() -> Client:
|
|
223
254
|
return Client(_bank(), _user(), _load_keyring())
|
|
224
255
|
|
|
@@ -284,6 +315,8 @@ _STEPS = {
|
|
|
284
315
|
"download": _download,
|
|
285
316
|
"upload": _upload,
|
|
286
317
|
"pain002": _pain002,
|
|
318
|
+
"haa": _haa,
|
|
319
|
+
"htd": _htd,
|
|
287
320
|
}
|
|
288
321
|
|
|
289
322
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ebicsclient"
|
|
7
|
-
version = "
|
|
7
|
+
version = "1.3.1"
|
|
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"
|
|
@@ -12,7 +12,7 @@ license = { file = "LICENSE.md" }
|
|
|
12
12
|
authors = [{ name = "Lukas Schulze" }]
|
|
13
13
|
keywords = ["ebics", "banking", "iso20022", "camt", "h005", "fintech"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Intended Audience :: Financial and Insurance Industry",
|
|
18
18
|
"License :: Other/Proprietary License",
|
|
@@ -30,6 +30,7 @@ from ebicsclient.errors import (
|
|
|
30
30
|
MessageFormatError,
|
|
31
31
|
MissingDependencyError,
|
|
32
32
|
ProtocolError,
|
|
33
|
+
ResponseAuthenticationError,
|
|
33
34
|
Retryability,
|
|
34
35
|
ReturnCodeError,
|
|
35
36
|
TransportError,
|
|
@@ -63,11 +64,13 @@ from ebicsclient.models import (
|
|
|
63
64
|
Keyring,
|
|
64
65
|
Letter,
|
|
65
66
|
Notification,
|
|
67
|
+
OrderTypeInfo,
|
|
66
68
|
OutputFormat,
|
|
67
69
|
PaymentStatus,
|
|
68
70
|
PaymentStatusReport,
|
|
69
71
|
Statement,
|
|
70
72
|
StatusReason,
|
|
73
|
+
SubscriberInfo,
|
|
71
74
|
TransactionStatus,
|
|
72
75
|
UploadPayload,
|
|
73
76
|
User,
|
|
@@ -116,15 +119,18 @@ __all__ = [
|
|
|
116
119
|
"MessageFormatError",
|
|
117
120
|
"MissingDependencyError",
|
|
118
121
|
"Notification",
|
|
122
|
+
"OrderTypeInfo",
|
|
119
123
|
"OutputFormat",
|
|
120
124
|
"PaymentStatus",
|
|
121
125
|
"PaymentStatusReport",
|
|
122
126
|
"ProtocolError",
|
|
127
|
+
"ResponseAuthenticationError",
|
|
123
128
|
"Retryability",
|
|
124
129
|
"ReturnCodeError",
|
|
125
130
|
"SelfSignedCertificateProvider",
|
|
126
131
|
"Statement",
|
|
127
132
|
"StatusReason",
|
|
133
|
+
"SubscriberInfo",
|
|
128
134
|
"TransactionStatus",
|
|
129
135
|
"TransportError",
|
|
130
136
|
"TrustAnchorVerifier",
|
|
@@ -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")
|
|
@@ -32,6 +32,7 @@ from ebicsclient.models import (
|
|
|
32
32
|
OutputFormat,
|
|
33
33
|
PaymentStatusReport,
|
|
34
34
|
Statement,
|
|
35
|
+
SubscriberInfo,
|
|
35
36
|
User,
|
|
36
37
|
)
|
|
37
38
|
from ebicsclient.protocol import h005
|
|
@@ -240,13 +241,31 @@ class Client:
|
|
|
240
241
|
ReturnCodeError: the bank reported a non-OK return code (e.g. no data available).
|
|
241
242
|
CryptoError: the order data could not be decrypted.
|
|
242
243
|
"""
|
|
243
|
-
|
|
244
|
-
raise ClientStateError("Download requires the bank's keys; call hpb() first")
|
|
244
|
+
bank_keys = self._require_bank_keys("Download")
|
|
245
245
|
logger.info("Download: opening a %s/%s transaction", btf.service_name, btf.message_name)
|
|
246
246
|
request = h005.build_download_initialisation_request(
|
|
247
|
-
self._bank, self._user, self._keyring,
|
|
247
|
+
self._bank, self._user, self._keyring, bank_keys, btf
|
|
248
|
+
)
|
|
249
|
+
return self._run_download_transaction(request, bank_keys)
|
|
250
|
+
|
|
251
|
+
def _require_bank_keys(self, operation: str) -> BankKeys:
|
|
252
|
+
if self._bank_keys is None:
|
|
253
|
+
raise ClientStateError(f"{operation} requires the bank's keys; call hpb() first")
|
|
254
|
+
return self._bank_keys
|
|
255
|
+
|
|
256
|
+
def _post_verified(self, request: bytes, bank_keys: BankKeys) -> bytes:
|
|
257
|
+
# Every ebicsResponse is authenticated with the bank's X002 signature; verify it
|
|
258
|
+
# BEFORE trusting anything in the response — including its return code.
|
|
259
|
+
response = self._transport.post(request)
|
|
260
|
+
h005.verify_response_signature(response, bank_keys.authentication)
|
|
261
|
+
return response
|
|
262
|
+
|
|
263
|
+
def _run_download_transaction(
|
|
264
|
+
self, initialisation_request: bytes, bank_keys: BankKeys
|
|
265
|
+
) -> bytes:
|
|
266
|
+
initialisation = h005.parse_download_initialisation_response(
|
|
267
|
+
self._post_verified(initialisation_request, bank_keys)
|
|
248
268
|
)
|
|
249
|
-
initialisation = h005.parse_download_initialisation_response(self._transport.post(request))
|
|
250
269
|
|
|
251
270
|
# Order data is one base64 stream split into NumSegments pieces: fetch the rest, in
|
|
252
271
|
# order, driven by the authoritative segment count from the initialisation response.
|
|
@@ -259,7 +278,9 @@ class Client:
|
|
|
259
278
|
number,
|
|
260
279
|
last_segment=number == initialisation.num_segments,
|
|
261
280
|
)
|
|
262
|
-
segment = h005.parse_download_segment_response(
|
|
281
|
+
segment = h005.parse_download_segment_response(
|
|
282
|
+
self._post_verified(transfer, bank_keys)
|
|
283
|
+
)
|
|
263
284
|
segments.append(segment.order_data_segment)
|
|
264
285
|
|
|
265
286
|
# Acknowledge the completed transfer so the bank marks the download as delivered.
|
|
@@ -267,7 +288,7 @@ class Client:
|
|
|
267
288
|
receipt = h005.build_download_receipt_request(
|
|
268
289
|
self._bank, self._keyring, initialisation.transaction_id
|
|
269
290
|
)
|
|
270
|
-
h005.parse_download_receipt_response(self.
|
|
291
|
+
h005.parse_download_receipt_response(self._post_verified(receipt, bank_keys))
|
|
271
292
|
logger.info(
|
|
272
293
|
"Download: received %d segment(s) for transaction %s",
|
|
273
294
|
initialisation.num_segments,
|
|
@@ -281,6 +302,60 @@ class Client:
|
|
|
281
302
|
self._keyring.encryption, initialisation.transaction_key, encrypted_order_data
|
|
282
303
|
)
|
|
283
304
|
|
|
305
|
+
def available_order_types(self) -> list[BusinessTransactionFormat]:
|
|
306
|
+
"""Download the order types for which the bank currently holds data (HAA).
|
|
307
|
+
|
|
308
|
+
Runs the administrative ``HAA`` download and returns the Business Transaction
|
|
309
|
+
Formats with data waiting — an authoritative "is there anything to fetch?" check.
|
|
310
|
+
The bank's keys must already be available (call :meth:`hpb` first).
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
The order types with data available (empty when nothing is waiting).
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
ClientStateError: the bank's keys have not been fetched yet (run HPB first).
|
|
317
|
+
TransportError: a request could not be delivered.
|
|
318
|
+
ProtocolError: a response could not be parsed.
|
|
319
|
+
ResponseAuthenticationError: a response failed signature verification.
|
|
320
|
+
ReturnCodeError: the bank rejected the request.
|
|
321
|
+
CryptoError: the order data could not be decrypted.
|
|
322
|
+
"""
|
|
323
|
+
bank_keys = self._require_bank_keys("HAA")
|
|
324
|
+
logger.info("HAA: requesting the order types with data available")
|
|
325
|
+
request = h005.build_admin_download_initialisation_request(
|
|
326
|
+
self._bank, self._user, self._keyring, bank_keys, "HAA"
|
|
327
|
+
)
|
|
328
|
+
return h005.parse_available_order_types(
|
|
329
|
+
self._run_download_transaction(request, bank_keys)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def subscriber_info(self) -> SubscriberInfo:
|
|
333
|
+
"""Download the bank's registered data for this subscriber (HTD).
|
|
334
|
+
|
|
335
|
+
Runs the administrative ``HTD`` download and returns the bank's authoritative
|
|
336
|
+
view of the subscriber: the user's status and name, the permissions granted, and
|
|
337
|
+
every order type registered for the partner — the ground truth for diagnosing
|
|
338
|
+
rejected order types. The bank's keys must already be available (call :meth:`hpb`
|
|
339
|
+
first).
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
The subscriber's registered data.
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
ClientStateError: the bank's keys have not been fetched yet (run HPB first).
|
|
346
|
+
TransportError: a request could not be delivered.
|
|
347
|
+
ProtocolError: a response could not be parsed.
|
|
348
|
+
ResponseAuthenticationError: a response failed signature verification.
|
|
349
|
+
ReturnCodeError: the bank rejected the request.
|
|
350
|
+
CryptoError: the order data could not be decrypted.
|
|
351
|
+
"""
|
|
352
|
+
bank_keys = self._require_bank_keys("HTD")
|
|
353
|
+
logger.info("HTD: requesting the subscriber data registered at the bank")
|
|
354
|
+
request = h005.build_admin_download_initialisation_request(
|
|
355
|
+
self._bank, self._user, self._keyring, bank_keys, "HTD"
|
|
356
|
+
)
|
|
357
|
+
return h005.parse_subscriber_info(self._run_download_transaction(request, bank_keys))
|
|
358
|
+
|
|
284
359
|
def download_statements(self) -> list[Statement]:
|
|
285
360
|
"""Download the end-of-period camt.053 statements and parse them.
|
|
286
361
|
|
|
@@ -390,14 +465,23 @@ class Client:
|
|
|
390
465
|
ReturnCodeError: the bank rejected the upload (e.g. a bad signature or order data).
|
|
391
466
|
CryptoError: the order data could not be signed or encrypted.
|
|
392
467
|
"""
|
|
393
|
-
if
|
|
394
|
-
|
|
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
|
+
)
|
|
476
|
+
bank_keys = self._require_bank_keys("Upload")
|
|
395
477
|
logger.info("Upload: opening a %s/%s transaction", btf.service_name, btf.message_name)
|
|
396
|
-
payload = h005.prepare_upload(self._user, self._keyring,
|
|
478
|
+
payload = h005.prepare_upload(self._user, self._keyring, bank_keys, order_data)
|
|
397
479
|
request = h005.build_upload_initialisation_request(
|
|
398
|
-
self._bank, self._user, self._keyring,
|
|
480
|
+
self._bank, self._user, self._keyring, bank_keys, btf, payload
|
|
481
|
+
)
|
|
482
|
+
transaction_id = h005.parse_upload_initialisation_response(
|
|
483
|
+
self._post_verified(request, bank_keys)
|
|
399
484
|
)
|
|
400
|
-
transaction_id = h005.parse_upload_initialisation_response(self._transport.post(request))
|
|
401
485
|
|
|
402
486
|
segments = payload.order_data_segments
|
|
403
487
|
for number, segment_data in enumerate(segments, start=1):
|
|
@@ -409,7 +493,7 @@ class Client:
|
|
|
409
493
|
segment_data,
|
|
410
494
|
last_segment=number == len(segments),
|
|
411
495
|
)
|
|
412
|
-
h005.parse_upload_transfer_response(self.
|
|
496
|
+
h005.parse_upload_transfer_response(self._post_verified(transfer, bank_keys))
|
|
413
497
|
logger.info(
|
|
414
498
|
"Upload: sent %d segment(s) for transaction %s", len(segments), transaction_id
|
|
415
499
|
)
|
|
@@ -125,6 +125,15 @@ class ProtocolError(EbicsError):
|
|
|
125
125
|
"""The bank's response violated the expected EBICS protocol or could not be parsed."""
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
class ResponseAuthenticationError(ProtocolError):
|
|
129
|
+
"""The bank's response failed authentication-signature verification.
|
|
130
|
+
|
|
131
|
+
Every ``ebicsResponse`` carries the bank's ``AuthSignature`` (X002) over its
|
|
132
|
+
``authenticate="true"`` nodes. A missing or invalid signature means the response
|
|
133
|
+
cannot be attributed to the bank — do not trust its contents. Fail closed.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
|
|
128
137
|
class BankKeyMismatchError(ProtocolError):
|
|
129
138
|
"""The bank's HPB public keys did not match the pinned values.
|
|
130
139
|
|
|
@@ -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
|
|