open-banking-io 0.1.0__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.
- open_banking_io-0.1.0/.gitignore +22 -0
- open_banking_io-0.1.0/PKG-INFO +77 -0
- open_banking_io-0.1.0/README.md +60 -0
- open_banking_io-0.1.0/pyproject.toml +30 -0
- open_banking_io-0.1.0/src/open_banking_io/__init__.py +29 -0
- open_banking_io-0.1.0/src/open_banking_io/client.py +283 -0
- open_banking_io-0.1.0/src/open_banking_io/envelope.py +70 -0
- open_banking_io-0.1.0/src/open_banking_io/models.py +102 -0
- open_banking_io-0.1.0/tests/conftest.py +37 -0
- open_banking_io-0.1.0/tests/test_crypto.py +50 -0
- open_banking_io-0.1.0/tests/test_integration.py +182 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# .NET
|
|
2
|
+
bin/
|
|
3
|
+
obj/
|
|
4
|
+
*.user
|
|
5
|
+
|
|
6
|
+
# Node
|
|
7
|
+
node_modules/
|
|
8
|
+
*.tsbuildinfo
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
.venv/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
*.pyc
|
|
16
|
+
|
|
17
|
+
# Build output (both node tsup + python build)
|
|
18
|
+
dist/
|
|
19
|
+
build/
|
|
20
|
+
|
|
21
|
+
# misc
|
|
22
|
+
.DS_Store
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-banking-io
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-to-server client for open-banking.io with local zero-knowledge envelope decryption.
|
|
5
|
+
Project-URL: Homepage, https://open-banking.io
|
|
6
|
+
Project-URL: Repository, https://github.com/open-banking-io/clients
|
|
7
|
+
Author: open-banking.io
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: banking,ecdh,open-banking,psd2,zero-knowledge
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: cryptography>=42.0
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-httpserver>=1.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# open-banking-io (Python)
|
|
19
|
+
|
|
20
|
+
Server-to-server client for [open-banking.io](https://open-banking.io). It authenticates with your
|
|
21
|
+
**API key** and decrypts the **zero-knowledge** data envelopes locally with your exported **private
|
|
22
|
+
key** — the service only ever returns ciphertext it cannot read.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install open-banking-io
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from open_banking_io import OpenBankingClient
|
|
30
|
+
|
|
31
|
+
# Load the credentials .json you exported from the app (API key + private key).
|
|
32
|
+
with OpenBankingClient.from_credentials("credentials.json") as client:
|
|
33
|
+
for account in client.get_accounts():
|
|
34
|
+
booked = next((b for b in account.balances if b.type == "ITBD"), None)
|
|
35
|
+
label = account.display_name or account.owner_name
|
|
36
|
+
print(f"{label} {account.iban}: {booked.amount if booked else None} {account.currency}")
|
|
37
|
+
|
|
38
|
+
page = client.get_transactions(account.id, limit=50)
|
|
39
|
+
for t in page.items:
|
|
40
|
+
print(f" {t.booking_date} {t.creditor_name or t.debtor_name} {t.amount} {t.currency}")
|
|
41
|
+
|
|
42
|
+
# Trigger an online sync (decrypts the account uid locally and posts it):
|
|
43
|
+
client.sync(account.id)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or construct it explicitly:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
client = OpenBankingClient(api_base_url, api_key, private_key_pkcs8)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
- `get_accounts() -> list[Account]` — decrypts each account's envelope, display name and balances.
|
|
55
|
+
- `get_transactions(account_id, *, date_from=None, date_to=None, limit=None, offset=None) -> TransactionPage`
|
|
56
|
+
- `get_connections() -> list[Connection]`
|
|
57
|
+
- `sync(account_id) -> SyncResult` — decrypts the account uid locally and posts it.
|
|
58
|
+
- `sync_all() -> SyncAllResult` — syncs every account that has an active session.
|
|
59
|
+
|
|
60
|
+
Amounts are exposed as `decimal.Decimal`. Models are plain `@dataclass`es.
|
|
61
|
+
|
|
62
|
+
## Encryption
|
|
63
|
+
|
|
64
|
+
Envelopes use **ECDH P-256 → HKDF-SHA256 → AES-256-GCM**. Decryption requires the private key from
|
|
65
|
+
your credentials bundle and happens entirely in-process. See the
|
|
66
|
+
[repo README](https://github.com/open-banking-io/clients) for the full scheme and the other language
|
|
67
|
+
clients (.NET, Node).
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python -m venv .venv
|
|
73
|
+
.venv/bin/pip install -e .[dev]
|
|
74
|
+
.venv/bin/pytest -q
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
MIT licensed.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# open-banking-io (Python)
|
|
2
|
+
|
|
3
|
+
Server-to-server client for [open-banking.io](https://open-banking.io). It authenticates with your
|
|
4
|
+
**API key** and decrypts the **zero-knowledge** data envelopes locally with your exported **private
|
|
5
|
+
key** — the service only ever returns ciphertext it cannot read.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install open-banking-io
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from open_banking_io import OpenBankingClient
|
|
13
|
+
|
|
14
|
+
# Load the credentials .json you exported from the app (API key + private key).
|
|
15
|
+
with OpenBankingClient.from_credentials("credentials.json") as client:
|
|
16
|
+
for account in client.get_accounts():
|
|
17
|
+
booked = next((b for b in account.balances if b.type == "ITBD"), None)
|
|
18
|
+
label = account.display_name or account.owner_name
|
|
19
|
+
print(f"{label} {account.iban}: {booked.amount if booked else None} {account.currency}")
|
|
20
|
+
|
|
21
|
+
page = client.get_transactions(account.id, limit=50)
|
|
22
|
+
for t in page.items:
|
|
23
|
+
print(f" {t.booking_date} {t.creditor_name or t.debtor_name} {t.amount} {t.currency}")
|
|
24
|
+
|
|
25
|
+
# Trigger an online sync (decrypts the account uid locally and posts it):
|
|
26
|
+
client.sync(account.id)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or construct it explicitly:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
client = OpenBankingClient(api_base_url, api_key, private_key_pkcs8)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
- `get_accounts() -> list[Account]` — decrypts each account's envelope, display name and balances.
|
|
38
|
+
- `get_transactions(account_id, *, date_from=None, date_to=None, limit=None, offset=None) -> TransactionPage`
|
|
39
|
+
- `get_connections() -> list[Connection]`
|
|
40
|
+
- `sync(account_id) -> SyncResult` — decrypts the account uid locally and posts it.
|
|
41
|
+
- `sync_all() -> SyncAllResult` — syncs every account that has an active session.
|
|
42
|
+
|
|
43
|
+
Amounts are exposed as `decimal.Decimal`. Models are plain `@dataclass`es.
|
|
44
|
+
|
|
45
|
+
## Encryption
|
|
46
|
+
|
|
47
|
+
Envelopes use **ECDH P-256 → HKDF-SHA256 → AES-256-GCM**. Decryption requires the private key from
|
|
48
|
+
your credentials bundle and happens entirely in-process. See the
|
|
49
|
+
[repo README](https://github.com/open-banking-io/clients) for the full scheme and the other language
|
|
50
|
+
clients (.NET, Node).
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m venv .venv
|
|
56
|
+
.venv/bin/pip install -e .[dev]
|
|
57
|
+
.venv/bin/pytest -q
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
MIT licensed.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "open-banking-io"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Server-to-server client for open-banking.io with local zero-knowledge envelope decryption."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "open-banking.io" }]
|
|
13
|
+
keywords = ["open-banking", "psd2", "banking", "zero-knowledge", "ecdh"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"cryptography>=42.0",
|
|
16
|
+
"httpx>=0.27",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"pytest-httpserver>=1.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://open-banking.io"
|
|
27
|
+
Repository = "https://github.com/open-banking-io/clients"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/open_banking_io"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""open-banking.io Python client.
|
|
2
|
+
|
|
3
|
+
Server-to-server client that authenticates with an API key and decrypts the
|
|
4
|
+
zero-knowledge data envelopes locally with your exported private key.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import OpenBankingClient
|
|
8
|
+
from .models import (
|
|
9
|
+
Account,
|
|
10
|
+
Balance,
|
|
11
|
+
Connection,
|
|
12
|
+
SyncAllResult,
|
|
13
|
+
SyncResult,
|
|
14
|
+
Transaction,
|
|
15
|
+
TransactionPage,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"OpenBankingClient",
|
|
20
|
+
"Account",
|
|
21
|
+
"Balance",
|
|
22
|
+
"Transaction",
|
|
23
|
+
"TransactionPage",
|
|
24
|
+
"Connection",
|
|
25
|
+
"SyncResult",
|
|
26
|
+
"SyncAllResult",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Server-to-server client for open-banking.io.
|
|
2
|
+
|
|
3
|
+
Authenticates with an API key (``X-Api-Key``) and decrypts the zero-knowledge data
|
|
4
|
+
envelopes locally with the exported private key -- the service only ever returns
|
|
5
|
+
ciphertext it cannot read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import envelope
|
|
19
|
+
from .models import (
|
|
20
|
+
Account,
|
|
21
|
+
Balance,
|
|
22
|
+
Connection,
|
|
23
|
+
SyncAllResult,
|
|
24
|
+
SyncResult,
|
|
25
|
+
Transaction,
|
|
26
|
+
TransactionPage,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_date(value: str | None) -> date | None:
|
|
31
|
+
if not value:
|
|
32
|
+
return None
|
|
33
|
+
return date.fromisoformat(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_datetime(value: str | None) -> datetime | None:
|
|
37
|
+
if not value:
|
|
38
|
+
return None
|
|
39
|
+
# Normalize a trailing 'Z' which datetime.fromisoformat handles only on 3.11+.
|
|
40
|
+
if value.endswith("Z"):
|
|
41
|
+
value = value[:-1] + "+00:00"
|
|
42
|
+
return datetime.fromisoformat(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_decimal(value: str | None) -> Decimal:
|
|
46
|
+
if value is None or value == "":
|
|
47
|
+
return Decimal(0)
|
|
48
|
+
return Decimal(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_decimal_nullable(value: str | None) -> Decimal | None:
|
|
52
|
+
if value is None or value == "":
|
|
53
|
+
return None
|
|
54
|
+
return Decimal(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OpenBankingClient:
|
|
58
|
+
"""Decrypting client for the open-banking.io API."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_base_url: str,
|
|
63
|
+
api_key: str,
|
|
64
|
+
private_key_pkcs8: str,
|
|
65
|
+
http_client: httpx.Client | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if not api_base_url or not api_base_url.strip():
|
|
68
|
+
raise ValueError("api_base_url is required")
|
|
69
|
+
if not api_key or not api_key.strip():
|
|
70
|
+
raise ValueError("api_key is required")
|
|
71
|
+
if not private_key_pkcs8 or not private_key_pkcs8.strip():
|
|
72
|
+
raise ValueError("private_key_pkcs8 is required")
|
|
73
|
+
|
|
74
|
+
self._private_key = envelope.load_private_key(private_key_pkcs8)
|
|
75
|
+
self._owns_http = http_client is None
|
|
76
|
+
self._http = http_client or httpx.Client()
|
|
77
|
+
self._http.base_url = httpx.URL(api_base_url.rstrip("/") + "/")
|
|
78
|
+
self._http.headers["X-Api-Key"] = api_key
|
|
79
|
+
|
|
80
|
+
# -- Construction ----------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_credentials(
|
|
84
|
+
cls, path_or_json: str, http_client: httpx.Client | None = None
|
|
85
|
+
) -> "OpenBankingClient":
|
|
86
|
+
"""Builds a client from a credentials-bundle JSON string or a path to a bundle file."""
|
|
87
|
+
if os.path.exists(path_or_json):
|
|
88
|
+
with open(path_or_json, "r", encoding="utf-8") as fh:
|
|
89
|
+
raw = fh.read()
|
|
90
|
+
else:
|
|
91
|
+
raw = path_or_json
|
|
92
|
+
|
|
93
|
+
bundle = json.loads(raw)
|
|
94
|
+
api_base_url = bundle.get("apiBaseUrl", "")
|
|
95
|
+
api_key = bundle.get("apiKey")
|
|
96
|
+
if not api_key:
|
|
97
|
+
raise ValueError("The credentials bundle has no apiKey")
|
|
98
|
+
|
|
99
|
+
enc_key = bundle.get("encryptionKey") or {}
|
|
100
|
+
private_key = enc_key.get("privateKey") or enc_key.get("privateKeyPkcs8B64")
|
|
101
|
+
if not private_key:
|
|
102
|
+
raise ValueError("The credentials bundle has no encryption private key")
|
|
103
|
+
|
|
104
|
+
return cls(api_base_url, api_key, private_key, http_client)
|
|
105
|
+
|
|
106
|
+
# -- Public API ------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def get_accounts(self) -> list[Account]:
|
|
109
|
+
"""Lists the user's accounts with all sensitive fields decrypted."""
|
|
110
|
+
wires = self._get_account_wires()
|
|
111
|
+
return [self._map_account(w) for w in wires]
|
|
112
|
+
|
|
113
|
+
def get_transactions(
|
|
114
|
+
self,
|
|
115
|
+
account_id: str,
|
|
116
|
+
*,
|
|
117
|
+
date_from: date | str | None = None,
|
|
118
|
+
date_to: date | str | None = None,
|
|
119
|
+
limit: int | None = None,
|
|
120
|
+
offset: int | None = None,
|
|
121
|
+
) -> TransactionPage:
|
|
122
|
+
"""Returns a page of an account's statement, newest first, with decrypted fields."""
|
|
123
|
+
params: dict[str, Any] = {}
|
|
124
|
+
if date_from is not None:
|
|
125
|
+
params["from"] = date_from.isoformat() if isinstance(date_from, date) else date_from
|
|
126
|
+
if date_to is not None:
|
|
127
|
+
params["to"] = date_to.isoformat() if isinstance(date_to, date) else date_to
|
|
128
|
+
if limit is not None:
|
|
129
|
+
params["limit"] = limit
|
|
130
|
+
if offset is not None:
|
|
131
|
+
params["offset"] = offset
|
|
132
|
+
|
|
133
|
+
resp = self._http.get(f"api/accounts/{account_id}/transactions", params=params)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
page = resp.json()
|
|
136
|
+
|
|
137
|
+
items = [self._map_transaction(t) for t in page.get("items", [])]
|
|
138
|
+
return TransactionPage(items=items, total=page.get("total", 0))
|
|
139
|
+
|
|
140
|
+
def get_connections(self) -> list[Connection]:
|
|
141
|
+
"""Lists the user's bank connections."""
|
|
142
|
+
resp = self._http.get("api/connections")
|
|
143
|
+
resp.raise_for_status()
|
|
144
|
+
return [
|
|
145
|
+
Connection(
|
|
146
|
+
session_id=c.get("sessionId", ""),
|
|
147
|
+
aspsp_name=c.get("aspspName", ""),
|
|
148
|
+
aspsp_country=c.get("aspspCountry", ""),
|
|
149
|
+
valid_until=_parse_datetime(c.get("validUntil")),
|
|
150
|
+
status=c.get("status", ""),
|
|
151
|
+
account_count=c.get("accountCount", 0),
|
|
152
|
+
last_synced_at=_parse_datetime(c.get("lastSyncedAt")),
|
|
153
|
+
psu_type=c.get("psuType"),
|
|
154
|
+
)
|
|
155
|
+
for c in resp.json()
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
def sync(self, account_id: str) -> SyncResult:
|
|
159
|
+
"""Triggers an online sync of one account.
|
|
160
|
+
|
|
161
|
+
Decrypts that account's Enable Banking uid and posts it, so the service can
|
|
162
|
+
fetch fresh data without ever holding the uid in plaintext.
|
|
163
|
+
"""
|
|
164
|
+
wires = self._get_account_wires()
|
|
165
|
+
account = next((a for a in wires if a.get("id") == account_id), None)
|
|
166
|
+
if account is None:
|
|
167
|
+
raise ValueError(f"Account {account_id} not found")
|
|
168
|
+
uid = self._decrypt_uid(account)
|
|
169
|
+
if uid is None:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"Account has no active session (reconnect required) -- cannot sync"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
resp = self._http.post(f"api/accounts/{account_id}/sync", json={"uid": uid})
|
|
175
|
+
resp.raise_for_status()
|
|
176
|
+
result = resp.json()
|
|
177
|
+
return SyncResult(
|
|
178
|
+
new_transactions=result.get("newTransactions", 0),
|
|
179
|
+
total_fetched=result.get("totalFetched", 0),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def sync_all(self) -> SyncAllResult:
|
|
183
|
+
"""Triggers an online sync of every account that has an active session."""
|
|
184
|
+
wires = self._get_account_wires()
|
|
185
|
+
items = []
|
|
186
|
+
for a in wires:
|
|
187
|
+
uid = self._decrypt_uid(a)
|
|
188
|
+
if uid is not None:
|
|
189
|
+
items.append({"accountId": a.get("id"), "uid": uid})
|
|
190
|
+
|
|
191
|
+
resp = self._http.post("api/sync", json={"items": items})
|
|
192
|
+
resp.raise_for_status()
|
|
193
|
+
result = resp.json()
|
|
194
|
+
return SyncAllResult(
|
|
195
|
+
accounts=result.get("accounts", 0),
|
|
196
|
+
new_transactions=result.get("newTransactions", 0),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# -- Internals -------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def _get_account_wires(self) -> list[dict[str, Any]]:
|
|
202
|
+
resp = self._http.get("api/accounts")
|
|
203
|
+
resp.raise_for_status()
|
|
204
|
+
return resp.json()
|
|
205
|
+
|
|
206
|
+
def _decrypt_uid(self, account: dict[str, Any]) -> str | None:
|
|
207
|
+
payload = envelope.decrypt_to_json(self._private_key, account.get("uidEnc"))
|
|
208
|
+
return payload.get("uid") if payload else None
|
|
209
|
+
|
|
210
|
+
def _map_account(self, a: dict[str, Any]) -> Account:
|
|
211
|
+
acc = envelope.decrypt_to_json(self._private_key, a.get("enc")) or {}
|
|
212
|
+
name = envelope.decrypt_to_json(self._private_key, a.get("displayNameEnc")) or {}
|
|
213
|
+
|
|
214
|
+
balances = []
|
|
215
|
+
for b in a.get("balances", []):
|
|
216
|
+
dec = envelope.decrypt_to_json(self._private_key, b.get("enc")) or {}
|
|
217
|
+
balances.append(
|
|
218
|
+
Balance(
|
|
219
|
+
type=b.get("type", ""),
|
|
220
|
+
currency=b.get("currency", ""),
|
|
221
|
+
reference_date=_parse_date(b.get("referenceDate")),
|
|
222
|
+
name=dec.get("name"),
|
|
223
|
+
amount=_parse_decimal(dec.get("amount")),
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return Account(
|
|
228
|
+
id=a.get("id", ""),
|
|
229
|
+
aspsp_name=a.get("aspspName", ""),
|
|
230
|
+
aspsp_country=a.get("aspspCountry", ""),
|
|
231
|
+
currency=a.get("currency", ""),
|
|
232
|
+
account_type=a.get("accountType"),
|
|
233
|
+
bic=a.get("bic"),
|
|
234
|
+
needs_reconnect=a.get("needsReconnect", False),
|
|
235
|
+
iban=acc.get("iban"),
|
|
236
|
+
bban=acc.get("bban"),
|
|
237
|
+
owner_name=acc.get("ownerName"),
|
|
238
|
+
account_name=acc.get("accountName"),
|
|
239
|
+
product=acc.get("product"),
|
|
240
|
+
display_name=name.get("displayName"),
|
|
241
|
+
balances=balances,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _map_transaction(self, t: dict[str, Any]) -> Transaction:
|
|
245
|
+
d = envelope.decrypt_to_json(self._private_key, t.get("enc")) or {}
|
|
246
|
+
return Transaction(
|
|
247
|
+
id=t.get("id", ""),
|
|
248
|
+
currency=t.get("currency", ""),
|
|
249
|
+
credit_debit_indicator=t.get("creditDebitIndicator", ""),
|
|
250
|
+
status=t.get("status"),
|
|
251
|
+
booking_date=_parse_date(t.get("bookingDate")),
|
|
252
|
+
value_date=_parse_date(t.get("valueDate")),
|
|
253
|
+
transaction_date=_parse_date(t.get("transactionDate")),
|
|
254
|
+
bank_transaction_code=t.get("bankTransactionCode"),
|
|
255
|
+
amount=_parse_decimal(d.get("amount")),
|
|
256
|
+
creditor_name=d.get("creditorName"),
|
|
257
|
+
creditor_iban=d.get("creditorIban"),
|
|
258
|
+
creditor_bban=d.get("creditorBban"),
|
|
259
|
+
creditor_agent_bic=d.get("creditorAgentBic"),
|
|
260
|
+
debtor_name=d.get("debtorName"),
|
|
261
|
+
debtor_iban=d.get("debtorIban"),
|
|
262
|
+
debtor_bban=d.get("debtorBban"),
|
|
263
|
+
debtor_agent_bic=d.get("debtorAgentBic"),
|
|
264
|
+
remittance_information=d.get("remittanceInformation"),
|
|
265
|
+
note=d.get("note"),
|
|
266
|
+
reference_number=d.get("referenceNumber"),
|
|
267
|
+
exchange_rate=d.get("exchangeRate"),
|
|
268
|
+
merchant_category_code=d.get("merchantCategoryCode"),
|
|
269
|
+
balance_after_transaction=_parse_decimal_nullable(d.get("balanceAfter")),
|
|
270
|
+
balance_after_currency=d.get("balanceAfterCurrency"),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# -- Lifecycle -------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
if self._owns_http:
|
|
277
|
+
self._http.close()
|
|
278
|
+
|
|
279
|
+
def __enter__(self) -> "OpenBankingClient":
|
|
280
|
+
return self
|
|
281
|
+
|
|
282
|
+
def __exit__(self, *exc: object) -> None:
|
|
283
|
+
self.close()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Decrypts open-banking.io's zero-knowledge data envelopes.
|
|
2
|
+
|
|
3
|
+
Scheme: ephemeral ECDH on NIST P-256 -> HKDF-SHA256 -> AES-256-GCM.
|
|
4
|
+
Wire: ``version(1)=0x01 | ephemeralPublicKeyRaw(65) | nonce(12) | tag(16) | ciphertext``.
|
|
5
|
+
Only the user's private key can decrypt -- the service stores ciphertext it cannot read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
17
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
18
|
+
|
|
19
|
+
_VERSION = 0x01
|
|
20
|
+
_POINT_LEN = 65
|
|
21
|
+
_NONCE_LEN = 12
|
|
22
|
+
_TAG_LEN = 16
|
|
23
|
+
_HKDF_SALT = b"\x00" * 32
|
|
24
|
+
_HKDF_INFO = b"bank.core.ci/zk/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_private_key(private_key_pkcs8_b64: str) -> ec.EllipticCurvePrivateKey:
|
|
28
|
+
"""Loads a base64 PKCS#8 EC (SECP256R1) private key."""
|
|
29
|
+
der = base64.b64decode(private_key_pkcs8_b64)
|
|
30
|
+
key = serialization.load_der_private_key(der, password=None)
|
|
31
|
+
if not isinstance(key, ec.EllipticCurvePrivateKey):
|
|
32
|
+
raise ValueError("Private key is not an EC key")
|
|
33
|
+
return key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decrypt(private_key: ec.EllipticCurvePrivateKey, envelope: bytes) -> bytes:
|
|
37
|
+
"""Decrypts the raw bytes of a zero-knowledge envelope."""
|
|
38
|
+
if len(envelope) < 1 + _POINT_LEN + _NONCE_LEN + _TAG_LEN or envelope[0] != _VERSION:
|
|
39
|
+
raise ValueError("Invalid or unsupported envelope")
|
|
40
|
+
|
|
41
|
+
eph_pub_bytes = envelope[1 : 1 + _POINT_LEN]
|
|
42
|
+
nonce = envelope[1 + _POINT_LEN : 1 + _POINT_LEN + _NONCE_LEN]
|
|
43
|
+
tag = envelope[
|
|
44
|
+
1 + _POINT_LEN + _NONCE_LEN : 1 + _POINT_LEN + _NONCE_LEN + _TAG_LEN
|
|
45
|
+
]
|
|
46
|
+
ciphertext = envelope[1 + _POINT_LEN + _NONCE_LEN + _TAG_LEN :]
|
|
47
|
+
|
|
48
|
+
eph_pub = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
49
|
+
ec.SECP256R1(), eph_pub_bytes
|
|
50
|
+
)
|
|
51
|
+
shared = private_key.exchange(ec.ECDH(), eph_pub)
|
|
52
|
+
|
|
53
|
+
key = HKDF(
|
|
54
|
+
algorithm=hashes.SHA256(),
|
|
55
|
+
length=32,
|
|
56
|
+
salt=_HKDF_SALT,
|
|
57
|
+
info=_HKDF_INFO,
|
|
58
|
+
).derive(shared)
|
|
59
|
+
|
|
60
|
+
return AESGCM(key).decrypt(nonce, ciphertext + tag, None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decrypt_to_json(
|
|
64
|
+
private_key: ec.EllipticCurvePrivateKey, envelope_b64: str | None
|
|
65
|
+
) -> dict[str, Any] | None:
|
|
66
|
+
"""Decrypts a base64 envelope and parses its JSON payload. ``None`` in -> ``None``."""
|
|
67
|
+
if envelope_b64 is None:
|
|
68
|
+
return None
|
|
69
|
+
plaintext = decrypt(private_key, base64.b64decode(envelope_b64))
|
|
70
|
+
return json.loads(plaintext)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Public, decrypted models for the open-banking.io client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Balance:
|
|
12
|
+
"""A balance snapshot. ``type`` is the ISO 20022 code (ITBD booked, ITAV available, ...)."""
|
|
13
|
+
|
|
14
|
+
type: str
|
|
15
|
+
name: str | None
|
|
16
|
+
amount: Decimal
|
|
17
|
+
currency: str
|
|
18
|
+
reference_date: date | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Account:
|
|
23
|
+
"""A bank account with its sensitive fields decrypted."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
aspsp_name: str
|
|
27
|
+
aspsp_country: str
|
|
28
|
+
currency: str
|
|
29
|
+
account_type: str | None
|
|
30
|
+
bic: str | None
|
|
31
|
+
needs_reconnect: bool
|
|
32
|
+
iban: str | None
|
|
33
|
+
bban: str | None
|
|
34
|
+
owner_name: str | None
|
|
35
|
+
account_name: str | None
|
|
36
|
+
product: str | None
|
|
37
|
+
display_name: str | None
|
|
38
|
+
balances: list[Balance] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Transaction:
|
|
43
|
+
"""A statement transaction with its sensitive fields decrypted."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
currency: str
|
|
47
|
+
credit_debit_indicator: str
|
|
48
|
+
status: str | None
|
|
49
|
+
booking_date: date | None
|
|
50
|
+
value_date: date | None
|
|
51
|
+
transaction_date: date | None
|
|
52
|
+
bank_transaction_code: str | None
|
|
53
|
+
amount: Decimal
|
|
54
|
+
creditor_name: str | None
|
|
55
|
+
creditor_iban: str | None
|
|
56
|
+
creditor_bban: str | None
|
|
57
|
+
creditor_agent_bic: str | None
|
|
58
|
+
debtor_name: str | None
|
|
59
|
+
debtor_iban: str | None
|
|
60
|
+
debtor_bban: str | None
|
|
61
|
+
debtor_agent_bic: str | None
|
|
62
|
+
remittance_information: str | None
|
|
63
|
+
note: str | None
|
|
64
|
+
reference_number: str | None
|
|
65
|
+
exchange_rate: str | None
|
|
66
|
+
merchant_category_code: str | None
|
|
67
|
+
balance_after_transaction: Decimal | None
|
|
68
|
+
balance_after_currency: str | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class TransactionPage:
|
|
73
|
+
"""A page of transactions, newest first."""
|
|
74
|
+
|
|
75
|
+
items: list[Transaction]
|
|
76
|
+
total: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Connection:
|
|
81
|
+
"""A bank connection (consent)."""
|
|
82
|
+
|
|
83
|
+
session_id: str
|
|
84
|
+
aspsp_name: str
|
|
85
|
+
aspsp_country: str
|
|
86
|
+
valid_until: datetime | None
|
|
87
|
+
status: str
|
|
88
|
+
account_count: int
|
|
89
|
+
last_synced_at: datetime | None
|
|
90
|
+
psu_type: str | None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class SyncResult:
|
|
95
|
+
new_transactions: int
|
|
96
|
+
total_fetched: int
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class SyncAllResult:
|
|
101
|
+
accounts: int
|
|
102
|
+
new_transactions: int
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
# python/tests/ -> python/ -> repo root -> fixtures/
|
|
7
|
+
FIXTURES = Path(__file__).resolve().parents[2] / "fixtures"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_fixture(*parts: str) -> dict | list:
|
|
11
|
+
with open(FIXTURES.joinpath(*parts), "r", encoding="utf-8") as fh:
|
|
12
|
+
return json.load(fh)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def fixtures_dir() -> Path:
|
|
17
|
+
return FIXTURES
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def keypair() -> dict:
|
|
22
|
+
return load_fixture("keypair.json")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def credentials() -> dict:
|
|
27
|
+
return load_fixture("credentials.json")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def envelopes() -> dict:
|
|
32
|
+
return load_fixture("envelopes.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def expected() -> dict:
|
|
37
|
+
return load_fixture("expected.json")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Wire-format interop: decrypt the shared fixtures and assert they match expected."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
from open_banking_io import envelope
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _priv(keypair):
|
|
9
|
+
return envelope.load_private_key(keypair["privateKeyPkcs8B64"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_account_envelope(keypair, envelopes, expected):
|
|
13
|
+
priv = _priv(keypair)
|
|
14
|
+
acc = envelope.decrypt_to_json(priv, envelopes["account"])
|
|
15
|
+
assert acc == expected["account"]
|
|
16
|
+
assert acc["iban"] == "DK6466952001724927"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_display_name_envelope(keypair, envelopes, expected):
|
|
20
|
+
priv = _priv(keypair)
|
|
21
|
+
name = envelope.decrypt_to_json(priv, envelopes["displayName"])
|
|
22
|
+
assert name == expected["displayName"]
|
|
23
|
+
assert name["displayName"] == "Drift"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_uid_envelope(keypair, envelopes, expected):
|
|
27
|
+
priv = _priv(keypair)
|
|
28
|
+
uid = envelope.decrypt_to_json(priv, envelopes["uid"])
|
|
29
|
+
assert uid["uid"] == expected["uid"]["uid"]
|
|
30
|
+
assert uid["uid"] == "c5d93aa7-5e23-4da0-ba88-42b9a584492c"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_balance_envelope(keypair, envelopes, expected):
|
|
34
|
+
priv = _priv(keypair)
|
|
35
|
+
bal = envelope.decrypt_to_json(priv, envelopes["balance"])
|
|
36
|
+
# The shared balance envelope is the booked (ITBD) balance.
|
|
37
|
+
assert Decimal(bal["amount"]) == Decimal(expected["balances"]["ITBD"]["amount"])
|
|
38
|
+
assert Decimal(bal["amount"]) == Decimal("828.13")
|
|
39
|
+
assert bal["name"] == "Tatic"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_transaction_envelope(keypair, envelopes, expected):
|
|
43
|
+
priv = _priv(keypair)
|
|
44
|
+
txn = envelope.decrypt_to_json(priv, envelopes["transaction"])
|
|
45
|
+
exp = expected["transaction"]
|
|
46
|
+
assert Decimal(txn["amount"]) == Decimal(exp["amount"])
|
|
47
|
+
assert Decimal(txn["amount"]) == Decimal("194.23")
|
|
48
|
+
assert txn["creditorName"] == "One.com"
|
|
49
|
+
assert txn["merchantCategoryCode"] == "4816"
|
|
50
|
+
assert Decimal(txn["balanceAfter"]) == Decimal("633.90")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Integration test: serve the shared API fixtures over HTTP and exercise the client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
from werkzeug import Response
|
|
9
|
+
|
|
10
|
+
from open_banking_io import OpenBankingClient
|
|
11
|
+
|
|
12
|
+
API_KEY = "obk_test_3f8b9c2e1a7d4655b0e9f2c1a8d7e6f5"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json_response(data, status=200) -> Response:
|
|
16
|
+
return Response(json.dumps(data), status=status, mimetype="application/json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _unauthorized() -> Response:
|
|
20
|
+
return _json_response({"error": "unauthorized"}, status=401)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def server(httpserver, fixtures_dir):
|
|
25
|
+
account_id = "11111111-1111-4111-8111-111111111111"
|
|
26
|
+
captured: dict = {}
|
|
27
|
+
|
|
28
|
+
def load(path):
|
|
29
|
+
return json.loads((fixtures_dir / "api" / path).read_text())
|
|
30
|
+
|
|
31
|
+
def static_handler(path):
|
|
32
|
+
data = load(path)
|
|
33
|
+
|
|
34
|
+
def handler(request):
|
|
35
|
+
if request.headers.get("X-Api-Key") != API_KEY:
|
|
36
|
+
return _unauthorized()
|
|
37
|
+
return _json_response(data)
|
|
38
|
+
|
|
39
|
+
return handler
|
|
40
|
+
|
|
41
|
+
httpserver.expect_request("/api/accounts", method="GET").respond_with_handler(
|
|
42
|
+
static_handler("accounts.json")
|
|
43
|
+
)
|
|
44
|
+
httpserver.expect_request(
|
|
45
|
+
f"/api/accounts/{account_id}/transactions", method="GET"
|
|
46
|
+
).respond_with_handler(static_handler("transactions.json"))
|
|
47
|
+
httpserver.expect_request("/api/connections", method="GET").respond_with_handler(
|
|
48
|
+
static_handler("connections.json")
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
sync_data = load("sync.json")
|
|
52
|
+
|
|
53
|
+
def sync_handler(request):
|
|
54
|
+
if request.headers.get("X-Api-Key") != API_KEY:
|
|
55
|
+
return _unauthorized()
|
|
56
|
+
captured["sync_body"] = json.loads(request.get_data())
|
|
57
|
+
return _json_response(sync_data)
|
|
58
|
+
|
|
59
|
+
httpserver.expect_request(
|
|
60
|
+
f"/api/accounts/{account_id}/sync", method="POST"
|
|
61
|
+
).respond_with_handler(sync_handler)
|
|
62
|
+
|
|
63
|
+
sync_all_data = load("sync-all.json")
|
|
64
|
+
|
|
65
|
+
def sync_all_handler(request):
|
|
66
|
+
if request.headers.get("X-Api-Key") != API_KEY:
|
|
67
|
+
return _unauthorized()
|
|
68
|
+
captured["sync_all_body"] = json.loads(request.get_data())
|
|
69
|
+
return _json_response(sync_all_data)
|
|
70
|
+
|
|
71
|
+
httpserver.expect_request("/api/sync", method="POST").respond_with_handler(
|
|
72
|
+
sync_all_handler
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
httpserver._captured = captured # type: ignore[attr-defined]
|
|
76
|
+
return httpserver
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _client(server, credentials):
|
|
80
|
+
return OpenBankingClient(
|
|
81
|
+
api_base_url=server.url_for("").rstrip("/"),
|
|
82
|
+
api_key=credentials["apiKey"],
|
|
83
|
+
private_key_pkcs8=credentials["encryptionKey"]["privateKey"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_get_accounts_decrypts(server, credentials):
|
|
88
|
+
with _client(server, credentials) as client:
|
|
89
|
+
accounts = client.get_accounts()
|
|
90
|
+
|
|
91
|
+
assert len(accounts) == 1
|
|
92
|
+
acc = accounts[0]
|
|
93
|
+
assert acc.iban == "DK6466952001724927"
|
|
94
|
+
assert acc.owner_name == "Tatic ApS"
|
|
95
|
+
assert acc.display_name == "Drift"
|
|
96
|
+
assert acc.aspsp_name == "Lunar"
|
|
97
|
+
|
|
98
|
+
by_type = {b.type: b for b in acc.balances}
|
|
99
|
+
assert by_type["ITBD"].amount == Decimal("828.13")
|
|
100
|
+
assert by_type["ITAV"].amount == Decimal("633.90")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_get_transactions_decrypts(server, credentials):
|
|
104
|
+
with _client(server, credentials) as client:
|
|
105
|
+
page = client.get_transactions(
|
|
106
|
+
"11111111-1111-4111-8111-111111111111", limit=50
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
assert page.total == 1
|
|
110
|
+
txn = page.items[0]
|
|
111
|
+
assert txn.amount == Decimal("194.23")
|
|
112
|
+
assert txn.creditor_name == "One.com"
|
|
113
|
+
assert txn.merchant_category_code == "4816"
|
|
114
|
+
assert txn.balance_after_transaction == Decimal("633.90")
|
|
115
|
+
assert txn.credit_debit_indicator == "DBIT"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_get_connections(server, credentials):
|
|
119
|
+
with _client(server, credentials) as client:
|
|
120
|
+
connections = client.get_connections()
|
|
121
|
+
|
|
122
|
+
assert len(connections) == 1
|
|
123
|
+
conn = connections[0]
|
|
124
|
+
assert conn.session_id == "22222222-2222-4222-8222-222222222222"
|
|
125
|
+
assert conn.aspsp_name == "Lunar"
|
|
126
|
+
assert conn.account_count == 1
|
|
127
|
+
assert conn.psu_type == "business"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_sync_posts_decrypted_uid(server, credentials):
|
|
131
|
+
with _client(server, credentials) as client:
|
|
132
|
+
result = client.sync("11111111-1111-4111-8111-111111111111")
|
|
133
|
+
|
|
134
|
+
assert result.total_fetched == 1
|
|
135
|
+
body = server._captured["sync_body"]
|
|
136
|
+
assert body == {"uid": "c5d93aa7-5e23-4da0-ba88-42b9a584492c"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_sync_all_posts_decrypted_uids(server, credentials):
|
|
140
|
+
with _client(server, credentials) as client:
|
|
141
|
+
result = client.sync_all()
|
|
142
|
+
|
|
143
|
+
assert result.accounts == 1
|
|
144
|
+
body = server._captured["sync_all_body"]
|
|
145
|
+
assert body["items"][0]["uid"] == "c5d93aa7-5e23-4da0-ba88-42b9a584492c"
|
|
146
|
+
assert body["items"][0]["accountId"] == "11111111-1111-4111-8111-111111111111"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_wrong_api_key_raises(server, credentials):
|
|
150
|
+
client = OpenBankingClient(
|
|
151
|
+
api_base_url=server.url_for("").rstrip("/"),
|
|
152
|
+
api_key="wrong-key",
|
|
153
|
+
private_key_pkcs8=credentials["encryptionKey"]["privateKey"],
|
|
154
|
+
)
|
|
155
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
156
|
+
client.get_accounts()
|
|
157
|
+
client.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_wrong_private_key_raises(server, credentials):
|
|
161
|
+
# A structurally valid but different EC key must fail to decrypt (GCM auth tag).
|
|
162
|
+
import base64
|
|
163
|
+
|
|
164
|
+
from cryptography.hazmat.primitives import serialization
|
|
165
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
166
|
+
|
|
167
|
+
other = ec.generate_private_key(ec.SECP256R1())
|
|
168
|
+
der = other.private_bytes(
|
|
169
|
+
encoding=serialization.Encoding.DER,
|
|
170
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
171
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
172
|
+
)
|
|
173
|
+
wrong_key = base64.b64encode(der).decode()
|
|
174
|
+
|
|
175
|
+
client = OpenBankingClient(
|
|
176
|
+
api_base_url=server.url_for("").rstrip("/"),
|
|
177
|
+
api_key=credentials["apiKey"],
|
|
178
|
+
private_key_pkcs8=wrong_key,
|
|
179
|
+
)
|
|
180
|
+
with pytest.raises(Exception):
|
|
181
|
+
client.get_accounts()
|
|
182
|
+
client.close()
|