qkdsec 0.2.0__py3-none-any.whl

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.
qkdsec/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """qkdsec — a developer toolkit for Quantum Key Distribution.
2
+
3
+ Subpackages:
4
+ qkdsec.proofs — numerical security proofs (key-rate lower bounds)
5
+ qkdsec.sim — BB84 simulator (Qiskit + classical backends)
6
+ qkdsec.client — ETSI GS QKD 014 REST client
7
+
8
+ Each subpackage may require optional dependencies. See README.md for install
9
+ options (e.g., ``pip install qkdsec[proofs]``).
10
+ """
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ __all__ = ["__version__"]
qkdsec/_cli.py ADDED
@@ -0,0 +1,287 @@
1
+ """Top-level ``qkdsec`` command-line interface.
2
+
3
+ Requires the ``cli`` (or ``doctor``) extra: ``pip install qkdsec[cli]``.
4
+
5
+ Subcommands::
6
+
7
+ qkdsec doctor <base_url> [--slave-sae-id SAE] [--cert PATH] [--key PATH]
8
+ [--ca-cert PATH] [--format text|json|html] [--output FILE]
9
+ [--no-consume] [--samples N]
10
+
11
+ qkdsec status <base_url> <slave_sae_id> [--cert ...] [--key ...] [--ca-cert ...]
12
+
13
+ qkdsec keys get <base_url> <slave_sae_id>
14
+ [--number N] [--size S] [--cert ...] [--key ...] [--ca-cert ...]
15
+
16
+ qkdsec keys retrieve <base_url> <slave_sae_id> <key_id>...
17
+ [--cert ...] [--key ...] [--ca-cert ...]
18
+
19
+ qkdsec version
20
+ """
21
+
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ try:
28
+ import typer
29
+ from rich.console import Console
30
+ except ImportError as e:
31
+ raise ImportError(
32
+ "qkdsec CLI requires extra dependencies. "
33
+ "Install with: pip install qkdsec[cli]"
34
+ ) from e
35
+
36
+ from . import __version__
37
+ from .client import ETSI014Client
38
+ from .client.errors import KMEError
39
+
40
+ app = typer.Typer(
41
+ name="qkdsec",
42
+ help="ETSI GS QKD 014 toolkit — KME probe, status, and key fetch.",
43
+ no_args_is_help=True,
44
+ add_completion=False,
45
+ )
46
+ keys_app = typer.Typer(
47
+ help="Fetch keys from a KME (enc_keys / dec_keys).",
48
+ no_args_is_help=True,
49
+ )
50
+ app.add_typer(keys_app, name="keys")
51
+
52
+ console = Console()
53
+ err_console = Console(stderr=True)
54
+
55
+
56
+ # ── Shared options ────────────────────────────────────────────────────────
57
+
58
+
59
+ def _build_client(
60
+ base_url: str,
61
+ cert: Optional[Path],
62
+ key: Optional[Path],
63
+ ca_cert: Optional[Path],
64
+ insecure: bool,
65
+ timeout: float,
66
+ ) -> ETSI014Client:
67
+ if insecure:
68
+ verify: bool | str = False
69
+ elif ca_cert is not None:
70
+ verify = str(ca_cert)
71
+ else:
72
+ verify = True
73
+
74
+ client_cert: str | tuple[str, str] | None
75
+ if cert and key:
76
+ client_cert = (str(cert), str(key))
77
+ elif cert:
78
+ client_cert = str(cert)
79
+ else:
80
+ client_cert = None
81
+
82
+ return ETSI014Client(
83
+ base_url,
84
+ client_cert=client_cert,
85
+ verify=verify,
86
+ timeout=timeout,
87
+ )
88
+
89
+
90
+ # ── doctor ────────────────────────────────────────────────────────────────
91
+
92
+
93
+ @app.command(help="Probe a KME for ETSI GS QKD 014 conformance.")
94
+ def doctor(
95
+ base_url: str = typer.Argument(..., help="KME base URL (e.g., https://kme.example.com)."),
96
+ slave_sae_id: str = typer.Option(
97
+ "sae-bob", "--slave-sae-id", "-s",
98
+ help="The slave SAE ID to probe against.",
99
+ ),
100
+ cert: Optional[Path] = typer.Option(
101
+ None, "--cert", help="Client certificate (PEM)."),
102
+ key: Optional[Path] = typer.Option(
103
+ None, "--key", help="Client private key (PEM)."),
104
+ ca_cert: Optional[Path] = typer.Option(
105
+ None, "--ca-cert", help="Path to CA certificate for TLS verification."),
106
+ insecure: bool = typer.Option(
107
+ False, "--insecure", help="Disable TLS verification (NOT for production)."),
108
+ fmt: str = typer.Option(
109
+ "text", "--format", "-f",
110
+ help="Output format: text, json, html.",
111
+ ),
112
+ output: Optional[Path] = typer.Option(
113
+ None, "--output", "-o", help="Write report to file instead of stdout."),
114
+ no_consume: bool = typer.Option(
115
+ False, "--no-consume",
116
+ help="Skip probes that consume real keys.",
117
+ ),
118
+ samples: int = typer.Option(
119
+ 5, "--samples", help="Latency probe sample count."),
120
+ timeout: float = typer.Option(
121
+ 30.0, "--timeout", help="Per-request HTTP timeout in seconds."),
122
+ ) -> None:
123
+ from .doctor import format_html, format_json, format_text, run_all
124
+
125
+ if fmt not in ("text", "json", "html"):
126
+ err_console.print(f"[red]Invalid format: {fmt}[/]")
127
+ raise typer.Exit(2)
128
+
129
+ client = _build_client(base_url, cert, key, ca_cert, insecure, timeout)
130
+ try:
131
+ report = run_all(
132
+ client,
133
+ slave_sae_id=slave_sae_id,
134
+ consume_keys=not no_consume,
135
+ latency_samples=samples,
136
+ )
137
+ finally:
138
+ client.close()
139
+
140
+ if fmt == "text":
141
+ rendered = format_text(report)
142
+ elif fmt == "json":
143
+ rendered = format_json(report)
144
+ else:
145
+ rendered = format_html(report)
146
+
147
+ if output:
148
+ output.write_text(rendered)
149
+ err_console.print(f"[dim]Report written to {output}[/]")
150
+ else:
151
+ # For text format we already have terminal control codes; print raw.
152
+ # For json/html, write plainly via sys.stdout.
153
+ if fmt == "text":
154
+ sys.stdout.write(rendered)
155
+ if not rendered.endswith("\n"):
156
+ sys.stdout.write("\n")
157
+ else:
158
+ print(rendered)
159
+
160
+ raise typer.Exit(0 if report.passed else 1)
161
+
162
+
163
+ # ── status ────────────────────────────────────────────────────────────────
164
+
165
+
166
+ @app.command(help="Fetch KME status for a slave SAE.")
167
+ def status(
168
+ base_url: str = typer.Argument(...),
169
+ slave_sae_id: str = typer.Argument(...),
170
+ cert: Optional[Path] = typer.Option(None, "--cert"),
171
+ key: Optional[Path] = typer.Option(None, "--key"),
172
+ ca_cert: Optional[Path] = typer.Option(None, "--ca-cert"),
173
+ insecure: bool = typer.Option(False, "--insecure"),
174
+ timeout: float = typer.Option(30.0, "--timeout"),
175
+ as_json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
176
+ ) -> None:
177
+ client = _build_client(base_url, cert, key, ca_cert, insecure, timeout)
178
+ try:
179
+ s = client.status(slave_sae_id)
180
+ except KMEError as e:
181
+ err_console.print(f"[red]Error:[/] {e}")
182
+ raise typer.Exit(1)
183
+ finally:
184
+ client.close()
185
+
186
+ payload = {
187
+ "source_kme_id": s.source_kme_id,
188
+ "target_kme_id": s.target_kme_id,
189
+ "master_sae_id": s.master_sae_id,
190
+ "slave_sae_id": s.slave_sae_id,
191
+ "key_size": s.key_size,
192
+ "stored_key_count": s.stored_key_count,
193
+ "max_key_count": s.max_key_count,
194
+ "max_key_per_request": s.max_key_per_request,
195
+ "max_key_size": s.max_key_size,
196
+ "min_key_size": s.min_key_size,
197
+ "max_sae_id_count": s.max_sae_id_count,
198
+ "status_extension": s.status_extension,
199
+ }
200
+ if as_json:
201
+ print(json.dumps(payload, indent=2))
202
+ else:
203
+ for k, v in payload.items():
204
+ console.print(f" [bold]{k}[/]: {v}")
205
+
206
+
207
+ # ── keys get / retrieve ───────────────────────────────────────────────────
208
+
209
+
210
+ @keys_app.command("get", help="Master SAE: fetch fresh keys via enc_keys.")
211
+ def keys_get(
212
+ base_url: str = typer.Argument(...),
213
+ slave_sae_id: str = typer.Argument(...),
214
+ number: int = typer.Option(1, "--number", "-n"),
215
+ size: int = typer.Option(256, "--size"),
216
+ method: str = typer.Option("GET", "--method"),
217
+ cert: Optional[Path] = typer.Option(None, "--cert"),
218
+ key: Optional[Path] = typer.Option(None, "--key"),
219
+ ca_cert: Optional[Path] = typer.Option(None, "--ca-cert"),
220
+ insecure: bool = typer.Option(False, "--insecure"),
221
+ timeout: float = typer.Option(30.0, "--timeout"),
222
+ as_json: bool = typer.Option(False, "--json"),
223
+ ) -> None:
224
+ client = _build_client(base_url, cert, key, ca_cert, insecure, timeout)
225
+ try:
226
+ keys = client.get_enc_keys(
227
+ slave_sae_id, number=number, size=size, method=method.upper()
228
+ )
229
+ except KMEError as e:
230
+ err_console.print(f"[red]Error:[/] {e}")
231
+ raise typer.Exit(1)
232
+ finally:
233
+ client.close()
234
+
235
+ if as_json:
236
+ print(json.dumps(
237
+ [{"key_id": k.key_id, "key_hex": k.key.hex(), "size_bits": k.size_bits}
238
+ for k in keys],
239
+ indent=2,
240
+ ))
241
+ else:
242
+ for k in keys:
243
+ console.print(f" [cyan]{k.key_id}[/] {k.key.hex()} ({k.size_bits}b)")
244
+
245
+
246
+ @keys_app.command("retrieve", help="Slave SAE: retrieve keys by key_ID via dec_keys.")
247
+ def keys_retrieve(
248
+ base_url: str = typer.Argument(...),
249
+ slave_sae_id: str = typer.Argument(...),
250
+ key_ids: list[str] = typer.Argument(..., help="One or more key_IDs."),
251
+ cert: Optional[Path] = typer.Option(None, "--cert"),
252
+ key: Optional[Path] = typer.Option(None, "--key"),
253
+ ca_cert: Optional[Path] = typer.Option(None, "--ca-cert"),
254
+ insecure: bool = typer.Option(False, "--insecure"),
255
+ timeout: float = typer.Option(30.0, "--timeout"),
256
+ as_json: bool = typer.Option(False, "--json"),
257
+ ) -> None:
258
+ client = _build_client(base_url, cert, key, ca_cert, insecure, timeout)
259
+ try:
260
+ keys = client.get_dec_keys(slave_sae_id, key_ids=key_ids)
261
+ except KMEError as e:
262
+ err_console.print(f"[red]Error:[/] {e}")
263
+ raise typer.Exit(1)
264
+ finally:
265
+ client.close()
266
+
267
+ if as_json:
268
+ print(json.dumps(
269
+ [{"key_id": k.key_id, "key_hex": k.key.hex(), "size_bits": k.size_bits}
270
+ for k in keys],
271
+ indent=2,
272
+ ))
273
+ else:
274
+ for k in keys:
275
+ console.print(f" [cyan]{k.key_id}[/] {k.key.hex()} ({k.size_bits}b)")
276
+
277
+
278
+ # ── version ───────────────────────────────────────────────────────────────
279
+
280
+
281
+ @app.command(help="Print the qkdsec version.")
282
+ def version() -> None:
283
+ print(__version__)
284
+
285
+
286
+ if __name__ == "__main__":
287
+ app()
@@ -0,0 +1,35 @@
1
+ """ETSI GS QKD 014 REST client for Key Management Entity (KME) systems.
2
+
3
+ Use this to fetch quantum keys from real QKD hardware (Toshiba, ID Quantique,
4
+ QuantumCTek, etc.) or from a local KME simulator. Both synchronous and
5
+ asynchronous clients are provided.
6
+
7
+ Sync example:
8
+ >>> from qkdsec.client import ETSI014Client
9
+ >>> kme = ETSI014Client("https://kme.example.com",
10
+ ... client_cert=("alice.crt", "alice.key"))
11
+ >>> status = kme.status("sae-bob")
12
+ >>> keys = kme.get_enc_keys("sae-bob", number=1, size=256)
13
+
14
+ Async example (requires ``pip install qkdsec[async]``)::
15
+
16
+ from qkdsec.client.aio import AsyncETSI014Client
17
+
18
+ async with AsyncETSI014Client("https://kme.example.com",
19
+ client_cert=("alice.crt", "alice.key")) as kme:
20
+ keys = await kme.get_enc_keys("sae-bob", number=1, size=256)
21
+ """
22
+
23
+ from ._types import KeyResponse, KeysContainer, StatusResponse
24
+ from .errors import KMEError, KMEHTTPError, KMENotFoundError
25
+ from .etsi014 import ETSI014Client
26
+
27
+ __all__ = [
28
+ "ETSI014Client",
29
+ "KeyResponse",
30
+ "KeysContainer",
31
+ "StatusResponse",
32
+ "KMEError",
33
+ "KMEHTTPError",
34
+ "KMENotFoundError",
35
+ ]
@@ -0,0 +1,86 @@
1
+ """Response types for the ETSI GS QKD 014 client.
2
+
3
+ The types here cover the full ETSI GS QKD 014 v1.1.1 spec, including vendor
4
+ extensions. All extension fields are optional; the client populates them when
5
+ present in the KME response and leaves them as ``None`` otherwise.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Optional
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class KeyResponse:
14
+ """A single key fetched from the KME (ETSI 014 §5.3.3 / §5.4.3).
15
+
16
+ Attributes
17
+ ----------
18
+ key_id : str
19
+ The KME-assigned key identifier (UUID). Used to retrieve the matching
20
+ key on the slave SAE via ``get_dec_keys``.
21
+ key : bytes
22
+ The raw key material. The wire format is base64; this field is
23
+ already decoded.
24
+ key_id_extension : dict, optional
25
+ Vendor-specific or non-standard extension data attached to the key
26
+ identifier (ETSI 014 §5.3.3 ``key_ID_extension``).
27
+ key_extension : dict, optional
28
+ Vendor-specific or non-standard extension data attached to the key
29
+ itself (ETSI 014 §5.3.3 ``key_extension``).
30
+ """
31
+
32
+ key_id: str
33
+ key: bytes
34
+ key_id_extension: Optional[dict[str, Any]] = None
35
+ key_extension: Optional[dict[str, Any]] = None
36
+
37
+ @property
38
+ def size_bits(self) -> int:
39
+ return len(self.key) * 8
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class KeysContainer:
44
+ """A container of keys returned by enc_keys / dec_keys (ETSI 014 §5.3.3).
45
+
46
+ Wraps ``list[KeyResponse]`` to also surface the optional container-level
47
+ extension. Iterating over a ``KeysContainer`` yields its keys, so most
48
+ callers can treat it like a list.
49
+
50
+ Attributes
51
+ ----------
52
+ keys : list[KeyResponse]
53
+ key_container_extension : dict, optional
54
+ Vendor-specific extension data attached to the container as a whole
55
+ (ETSI 014 §5.3.3 ``key_container_extension``).
56
+ """
57
+
58
+ keys: list[KeyResponse] = field(default_factory=list)
59
+ key_container_extension: Optional[dict[str, Any]] = None
60
+
61
+ def __iter__(self):
62
+ return iter(self.keys)
63
+
64
+ def __len__(self) -> int:
65
+ return len(self.keys)
66
+
67
+ def __getitem__(self, idx: int) -> KeyResponse:
68
+ return self.keys[idx]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class StatusResponse:
73
+ """KME status for a given slave SAE (ETSI GS QKD 014 §5.2)."""
74
+
75
+ source_kme_id: str
76
+ target_kme_id: str
77
+ master_sae_id: str
78
+ slave_sae_id: str
79
+ key_size: int
80
+ stored_key_count: int
81
+ max_key_count: int
82
+ max_key_per_request: int
83
+ max_key_size: int
84
+ min_key_size: int
85
+ max_sae_id_count: int
86
+ status_extension: Optional[dict[str, Any]] = None
qkdsec/client/aio.py ADDED
@@ -0,0 +1,236 @@
1
+ """Asynchronous ETSI GS QKD 014 client (httpx-based).
2
+
3
+ Requires the ``async`` extra: ``pip install qkdsec[async]``.
4
+
5
+ The async client mirrors the sync ``ETSI014Client`` API one-for-one. Parsers
6
+ are shared with the sync client to keep response handling consistent.
7
+
8
+ Example::
9
+
10
+ from qkdsec.client.aio import AsyncETSI014Client
11
+
12
+ async with AsyncETSI014Client(
13
+ "https://kme.example.com",
14
+ client_cert=("alice.crt", "alice.key"),
15
+ ) as kme:
16
+ status = await kme.status("sae-bob")
17
+ keys = await kme.get_enc_keys("sae-bob", number=1, size=256)
18
+ """
19
+
20
+ from typing import Any, Optional, Union
21
+
22
+ try:
23
+ import httpx
24
+ except ImportError as e:
25
+ raise ImportError(
26
+ "qkdsec.client.aio requires extra dependencies. "
27
+ "Install with: pip install qkdsec[async]"
28
+ ) from e
29
+
30
+ from ._types import KeyResponse, KeysContainer, StatusResponse
31
+ from .errors import KMEHTTPError, KMENotFoundError
32
+ from .etsi014 import ETSI014Client
33
+
34
+ _API_PREFIX = "/api/v1/keys"
35
+
36
+
37
+ CertType = Union[str, tuple[str, str], None]
38
+ VerifyType = Union[bool, str]
39
+
40
+
41
+ class AsyncETSI014Client:
42
+ """Asynchronous client for an ETSI GS QKD 014 KME.
43
+
44
+ Parameters mirror :class:`qkdsec.client.ETSI014Client` exactly.
45
+
46
+ Use as an async context manager to ensure the underlying httpx client
47
+ is closed::
48
+
49
+ async with AsyncETSI014Client(...) as kme:
50
+ await kme.status("sae-bob")
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ base_url: str,
56
+ *,
57
+ client_cert: CertType = None,
58
+ verify: VerifyType = True,
59
+ timeout: float = 30.0,
60
+ extra_headers: Optional[dict] = None,
61
+ client: Optional[httpx.AsyncClient] = None,
62
+ ):
63
+ self.base_url = base_url.rstrip("/")
64
+ self.client_cert = client_cert
65
+ self.verify = verify
66
+ self.timeout = timeout
67
+ self.extra_headers = dict(extra_headers or {})
68
+
69
+ if client is None:
70
+ self._client = httpx.AsyncClient(
71
+ cert=client_cert,
72
+ verify=verify,
73
+ timeout=timeout,
74
+ headers=self.extra_headers,
75
+ )
76
+ self._owns_client = True
77
+ else:
78
+ self._client = client
79
+ self._owns_client = False
80
+
81
+ # ── Status ─────────────────────────────────────────────────────────────
82
+
83
+ async def status(self, slave_sae_id: str) -> StatusResponse:
84
+ """Fetch KME status for the given slave SAE (ETSI 014 §5.2)."""
85
+ url = f"{self.base_url}{_API_PREFIX}/{slave_sae_id}/status"
86
+ data = await self._get_json(url)
87
+ return ETSI014Client._parse_status(data)
88
+
89
+ # ── enc_keys (master SAE) ──────────────────────────────────────────────
90
+
91
+ async def get_enc_keys(
92
+ self,
93
+ slave_sae_id: str,
94
+ *,
95
+ number: int = 1,
96
+ size: int = 256,
97
+ method: str = "GET",
98
+ additional_slave_sae_ids: Optional[list[str]] = None,
99
+ extension_mandatory: Optional[list[dict[str, Any]]] = None,
100
+ extension_optional: Optional[list[dict[str, Any]]] = None,
101
+ ) -> list[KeyResponse]:
102
+ """Fetch encryption keys for the master SAE (ETSI 014 §5.3)."""
103
+ container = await self.get_enc_keys_container(
104
+ slave_sae_id,
105
+ number=number,
106
+ size=size,
107
+ method=method,
108
+ additional_slave_sae_ids=additional_slave_sae_ids,
109
+ extension_mandatory=extension_mandatory,
110
+ extension_optional=extension_optional,
111
+ )
112
+ return container.keys
113
+
114
+ async def get_enc_keys_container(
115
+ self,
116
+ slave_sae_id: str,
117
+ *,
118
+ number: int = 1,
119
+ size: int = 256,
120
+ method: str = "GET",
121
+ additional_slave_sae_ids: Optional[list[str]] = None,
122
+ extension_mandatory: Optional[list[dict[str, Any]]] = None,
123
+ extension_optional: Optional[list[dict[str, Any]]] = None,
124
+ ) -> KeysContainer:
125
+ """Fetch encryption keys and return the full ETSI 014 §5.3 container."""
126
+ force_post = bool(
127
+ additional_slave_sae_ids
128
+ or extension_mandatory
129
+ or extension_optional
130
+ )
131
+ actual_method = "POST" if force_post else method.upper()
132
+
133
+ url = f"{self.base_url}{_API_PREFIX}/{slave_sae_id}/enc_keys"
134
+ if actual_method == "GET":
135
+ data = await self._get_json(
136
+ url, params={"number": number, "size": size}
137
+ )
138
+ elif actual_method == "POST":
139
+ body: dict[str, Any] = {"number": number, "size": size}
140
+ if additional_slave_sae_ids:
141
+ body["additional_slave_SAE_IDs"] = list(additional_slave_sae_ids)
142
+ if extension_mandatory:
143
+ body["extension_mandatory"] = list(extension_mandatory)
144
+ if extension_optional:
145
+ body["extension_optional"] = list(extension_optional)
146
+ data = await self._post_json(url, json=body)
147
+ else:
148
+ raise ValueError(
149
+ f"method must be 'GET' or 'POST', got {method!r}"
150
+ )
151
+ return ETSI014Client._parse_keys_container(data)
152
+
153
+ # ── dec_keys (slave SAE) ───────────────────────────────────────────────
154
+
155
+ async def get_dec_keys(
156
+ self,
157
+ slave_sae_id: str,
158
+ *,
159
+ key_ids: list[str],
160
+ key_id_extensions: Optional[dict[str, dict[str, Any]]] = None,
161
+ key_ids_extension: Optional[dict[str, Any]] = None,
162
+ ) -> list[KeyResponse]:
163
+ """Fetch specific keys by key_ID for the slave SAE (ETSI 014 §5.4)."""
164
+ container = await self.get_dec_keys_container(
165
+ slave_sae_id,
166
+ key_ids=key_ids,
167
+ key_id_extensions=key_id_extensions,
168
+ key_ids_extension=key_ids_extension,
169
+ )
170
+ return container.keys
171
+
172
+ async def get_dec_keys_container(
173
+ self,
174
+ slave_sae_id: str,
175
+ *,
176
+ key_ids: list[str],
177
+ key_id_extensions: Optional[dict[str, dict[str, Any]]] = None,
178
+ key_ids_extension: Optional[dict[str, Any]] = None,
179
+ ) -> KeysContainer:
180
+ """Fetch keys by key_ID and return the full ETSI 014 §5.4 container."""
181
+ if not key_ids:
182
+ raise ValueError("key_ids must be a non-empty list")
183
+ url = f"{self.base_url}{_API_PREFIX}/{slave_sae_id}/dec_keys"
184
+ ext_map = key_id_extensions or {}
185
+ items: list[dict[str, Any]] = []
186
+ for kid in key_ids:
187
+ entry: dict[str, Any] = {"key_ID": kid}
188
+ if kid in ext_map:
189
+ entry["key_ID_extension"] = ext_map[kid]
190
+ items.append(entry)
191
+ body: dict[str, Any] = {"key_IDs": items}
192
+ if key_ids_extension:
193
+ body["key_IDs_extension"] = key_ids_extension
194
+ data = await self._post_json(url, json=body)
195
+ return ETSI014Client._parse_keys_container(data)
196
+
197
+ # ── Lifecycle ──────────────────────────────────────────────────────────
198
+
199
+ async def aclose(self) -> None:
200
+ """Close the underlying HTTP client (if owned by this instance)."""
201
+ if self._owns_client:
202
+ await self._client.aclose()
203
+
204
+ async def __aenter__(self) -> "AsyncETSI014Client":
205
+ return self
206
+
207
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
208
+ await self.aclose()
209
+
210
+ # ── HTTP helpers ───────────────────────────────────────────────────────
211
+
212
+ async def _get_json(self, url: str, params: Optional[dict] = None) -> dict:
213
+ response = await self._client.get(url, params=params)
214
+ return self._handle_response(response)
215
+
216
+ async def _post_json(self, url: str, json: dict) -> dict:
217
+ response = await self._client.post(
218
+ url,
219
+ json=json,
220
+ headers={"Content-Type": "application/json"},
221
+ )
222
+ return self._handle_response(response)
223
+
224
+ @staticmethod
225
+ def _handle_response(response: "httpx.Response") -> dict:
226
+ if response.is_success:
227
+ return response.json()
228
+
229
+ try:
230
+ message = response.json().get("message", response.text)
231
+ except ValueError:
232
+ message = response.text
233
+
234
+ if response.status_code == 404:
235
+ raise KMENotFoundError(message)
236
+ raise KMEHTTPError(response.status_code, message)
@@ -0,0 +1,28 @@
1
+ """Exception types raised by the ETSI 014 client."""
2
+
3
+
4
+ class KMEError(Exception):
5
+ """Base class for all KME client errors."""
6
+
7
+
8
+ class KMEHTTPError(KMEError):
9
+ """Raised when the KME returns a non-2xx HTTP response.
10
+
11
+ Attributes
12
+ ----------
13
+ status_code : int
14
+ message : str
15
+ The KME's error message (or response body), if available.
16
+ """
17
+
18
+ def __init__(self, status_code: int, message: str):
19
+ super().__init__(f"KME returned HTTP {status_code}: {message}")
20
+ self.status_code = status_code
21
+ self.message = message
22
+
23
+
24
+ class KMENotFoundError(KMEHTTPError):
25
+ """Raised on HTTP 404 (e.g., key_ID not found or already retrieved)."""
26
+
27
+ def __init__(self, message: str):
28
+ super().__init__(404, message)