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 +14 -0
- qkdsec/_cli.py +287 -0
- qkdsec/client/__init__.py +35 -0
- qkdsec/client/_types.py +86 -0
- qkdsec/client/aio.py +236 -0
- qkdsec/client/errors.py +28 -0
- qkdsec/client/etsi014.py +327 -0
- qkdsec/doctor/__init__.py +41 -0
- qkdsec/doctor/probes.py +642 -0
- qkdsec/doctor/report.py +244 -0
- qkdsec/proofs/__init__.py +43 -0
- qkdsec/proofs/_api.py +25 -0
- qkdsec/proofs/_types.py +15 -0
- qkdsec/proofs/channels/__init__.py +6 -0
- qkdsec/proofs/channels/base.py +17 -0
- qkdsec/proofs/channels/decoy.py +42 -0
- qkdsec/proofs/channels/depolarizing.py +12 -0
- qkdsec/proofs/channels/loss.py +16 -0
- qkdsec/proofs/decoy_state.py +51 -0
- qkdsec/proofs/finite_size.py +13 -0
- qkdsec/proofs/protocols/__init__.py +4 -0
- qkdsec/proofs/protocols/base.py +20 -0
- qkdsec/proofs/protocols/bb84.py +32 -0
- qkdsec/proofs/sdp.py +54 -0
- qkdsec/sim/__init__.py +18 -0
- qkdsec/sim/_classical.py +59 -0
- qkdsec/sim/_qiskit.py +154 -0
- qkdsec/sim/bb84.py +219 -0
- qkdsec-0.2.0.dist-info/METADATA +215 -0
- qkdsec-0.2.0.dist-info/RECORD +34 -0
- qkdsec-0.2.0.dist-info/WHEEL +5 -0
- qkdsec-0.2.0.dist-info/entry_points.txt +2 -0
- qkdsec-0.2.0.dist-info/licenses/LICENSE +201 -0
- qkdsec-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
qkdsec/client/_types.py
ADDED
|
@@ -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)
|
qkdsec/client/errors.py
ADDED
|
@@ -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)
|