fipsign-sdk 0.5.2__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.
fipsign/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ fipsign-sdk · Post-quantum signing SDK for Python.
3
+ Uses ML-DSA-65 (NIST FIPS 204) — resistant to quantum computers.
4
+
5
+ Sign anything: users, orders, documents, devices, events.
6
+ The only required field is `sub` — any string identifying the entity.
7
+ """
8
+
9
+ from .client import PQAuth
10
+ from .errors import PQAuthError
11
+ from .middleware import flask_middleware, fastapi_middleware
12
+
13
+ __all__ = ["PQAuth", "PQAuthError", "flask_middleware", "fastapi_middleware"]
14
+ __version__ = "0.5.2"
@@ -0,0 +1,238 @@
1
+ """
2
+ AsyncPQAuth — async variant of PQAuth using httpx.
3
+
4
+ Install extra: pip install fipsign-sdk[async] (pulls in httpx)
5
+
6
+ All methods are identical to PQAuth but async.
7
+ Use this in FastAPI, aiohttp, or any asyncio-based application.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ try:
15
+ import httpx
16
+ except ImportError:
17
+ raise ImportError(
18
+ "AsyncPQAuth requires httpx. Install it with: pip install fipsign-sdk[async]"
19
+ )
20
+
21
+ from .errors import PQAuthError
22
+ from .types import (
23
+ HealthResult,
24
+ MonthlyEntry,
25
+ PackEntry,
26
+ PQToken,
27
+ RevokeResult,
28
+ SignMeta,
29
+ SignResult,
30
+ SignUsage,
31
+ UsageCurrent,
32
+ UsageResult,
33
+ VerifyResult,
34
+ WebhookGetResult,
35
+ WebhookInfo,
36
+ WebhookResult,
37
+ )
38
+
39
+ DEFAULT_BASE_URL = "https://api.fipsign.dev"
40
+ DEFAULT_TIMEOUT = 10
41
+
42
+
43
+ class AsyncWebhooks:
44
+ def __init__(self, client: "AsyncPQAuth") -> None:
45
+ self._client = client
46
+
47
+ async def register(self, url: str, events: Optional[List[str]] = None) -> WebhookResult:
48
+ body: dict = {"url": url}
49
+ if events is not None:
50
+ body["events"] = events
51
+ data = await self._client._request("POST", "/webhooks", json=body)
52
+ wh = data["webhook"]
53
+ return WebhookResult(
54
+ webhook=WebhookInfo(url=wh["url"], events=wh["events"], secret=wh.get("secret"))
55
+ )
56
+
57
+ async def get(self) -> WebhookGetResult:
58
+ data = await self._client._request("GET", "/webhooks")
59
+ wh = data.get("webhook")
60
+ if wh is None:
61
+ return WebhookGetResult(webhook=None)
62
+ return WebhookGetResult(webhook=WebhookInfo(url=wh["url"], events=wh["events"]))
63
+
64
+ async def delete(self) -> dict:
65
+ return await self._client._request("DELETE", "/webhooks")
66
+
67
+ async def test(self) -> dict:
68
+ return await self._client._request("POST", "/webhooks/test")
69
+
70
+
71
+ class AsyncPQAuth:
72
+ """
73
+ Async version of PQAuth. Use with ``async with`` or call ``await pq.aclose()`` when done.
74
+
75
+ Examples
76
+ --------
77
+ >>> async with AsyncPQAuth("pqa_your_key") as pq:
78
+ ... result = await pq.sign("user_123", role="admin")
79
+ ... v = await pq.verify(result.token)
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ api_key: str,
85
+ *,
86
+ base_url: str = DEFAULT_BASE_URL,
87
+ timeout: float = DEFAULT_TIMEOUT,
88
+ ) -> None:
89
+ if not api_key or not api_key.startswith("pqa_"):
90
+ raise PQAuthError(
91
+ 'Invalid API key — keys must start with "pqa_". '
92
+ "Get one at https://app.fipsign.dev",
93
+ "INVALID_API_KEY",
94
+ )
95
+ self._api_key = api_key
96
+ self._base_url = base_url.rstrip("/")
97
+ self._timeout = timeout
98
+ self._http = httpx.AsyncClient(
99
+ headers={
100
+ "Content-Type": "application/json",
101
+ "X-API-Key": self._api_key,
102
+ },
103
+ timeout=timeout,
104
+ )
105
+ self.webhooks = AsyncWebhooks(self)
106
+
107
+ async def __aenter__(self) -> "AsyncPQAuth":
108
+ return self
109
+
110
+ async def __aexit__(self, *args: Any) -> None:
111
+ await self.aclose()
112
+
113
+ async def aclose(self) -> None:
114
+ await self._http.aclose()
115
+
116
+ async def _request(
117
+ self,
118
+ method: str,
119
+ path: str,
120
+ *,
121
+ json: Optional[Dict[str, Any]] = None,
122
+ ) -> Dict[str, Any]:
123
+ url = f"{self._base_url}{path}"
124
+ try:
125
+ resp = await self._http.request(method, url, json=json)
126
+ except httpx.TimeoutException:
127
+ raise PQAuthError("Request timed out", "TIMEOUT")
128
+ except httpx.NetworkError as exc:
129
+ raise PQAuthError(f"Network error: {exc}", "NETWORK_ERROR")
130
+
131
+ try:
132
+ data = resp.json()
133
+ except ValueError:
134
+ raise PQAuthError(
135
+ f"Request failed with status {resp.status_code}",
136
+ "API_ERROR",
137
+ resp.status_code,
138
+ )
139
+
140
+ if not resp.is_success or not data.get("success", False):
141
+ raise PQAuthError(
142
+ data.get("error") or f"Request failed with status {resp.status_code}",
143
+ "API_ERROR",
144
+ resp.status_code,
145
+ )
146
+
147
+ return data
148
+
149
+ async def sign(
150
+ self,
151
+ sub: str,
152
+ *,
153
+ expires_in_seconds: Optional[int] = None,
154
+ **fields: Any,
155
+ ) -> SignResult:
156
+ if not sub:
157
+ raise PQAuthError('"sub" is required', "MISSING_SUB")
158
+ body: Dict[str, Any] = {"sub": sub, **fields}
159
+ if expires_in_seconds is not None:
160
+ body["expiresInSeconds"] = expires_in_seconds
161
+ data = await self._request("POST", "/sign", json=body)
162
+ t, m, u = data["token"], data["meta"], data["usage"]
163
+ return SignResult(
164
+ token=PQToken(payload=t["payload"], signature=t["signature"],
165
+ algorithm=t["algorithm"], issuedAt=t["issuedAt"]),
166
+ meta=SignMeta(algorithm=m["algorithm"], standard=m["standard"],
167
+ quantumResistant=m["quantumResistant"], expiresIn=m["expiresIn"],
168
+ issuedFor=m["issuedFor"], projectId=m["projectId"],
169
+ tokenCost=m["tokenCost"], source=m["source"]),
170
+ usage=SignUsage(freeRemaining=u["freeRemaining"], packRemaining=u["packRemaining"],
171
+ totalRemaining=u["totalRemaining"], month=u["month"]),
172
+ )
173
+
174
+ async def verify(self, token: PQToken) -> VerifyResult:
175
+ try:
176
+ data = await self._request("POST", "/verify", json={"token": token.to_dict()})
177
+ return VerifyResult(valid=True, payload=data.get("payload"))
178
+ except PQAuthError as exc:
179
+ return VerifyResult(valid=False, error=exc.message)
180
+ except Exception as exc:
181
+ return VerifyResult(valid=False, error=str(exc))
182
+
183
+ async def revoke(self, token: PQToken, reason: Optional[str] = None) -> RevokeResult:
184
+ body: Dict[str, Any] = {"token": token.to_dict()}
185
+ if reason is not None:
186
+ body["reason"] = reason
187
+ data = await self._request("POST", "/revoke", json=body)
188
+ return RevokeResult(
189
+ success=data.get("success", False),
190
+ message=data.get("message", ""),
191
+ revokedAt=data.get("revokedAt"),
192
+ sub=data.get("sub"),
193
+ expiresAt=data.get("expiresAt"),
194
+ note=data.get("note"),
195
+ )
196
+
197
+ async def usage(self) -> UsageResult:
198
+ data = await self._request("GET", "/usage")
199
+ c = data["current"]
200
+ return UsageResult(
201
+ current=UsageCurrent(
202
+ month=c["month"], freeUsed=c["freeUsed"],
203
+ freeRemaining=c["freeRemaining"], freeLimit=c["freeLimit"],
204
+ packRemaining=c["packRemaining"], totalRemaining=c["totalRemaining"],
205
+ ),
206
+ monthlyHistory=[
207
+ MonthlyEntry(month=e["month"], tokensUsed=e["tokensUsed"],
208
+ fromFree=e["fromFree"], fromPack=e["fromPack"])
209
+ for e in data.get("monthlyHistory", [])
210
+ ],
211
+ packs=[
212
+ PackEntry(id=p["id"], packType=p["packType"],
213
+ tokensPurchased=p["tokensPurchased"], purchasedAt=p["purchasedAt"],
214
+ paymentRef=p.get("paymentRef"))
215
+ for p in data.get("packs", [])
216
+ ],
217
+ developer=data.get("developer", {}),
218
+ note=data.get("note", ""),
219
+ )
220
+
221
+ async def preload_public_key(self) -> str:
222
+ resp = await self._http.get(f"{self._base_url}/public-key")
223
+ return resp.json()["publicKey"]
224
+
225
+ async def health(self) -> HealthResult:
226
+ try:
227
+ resp = await self._http.get(f"{self._base_url}/health")
228
+ data = resp.json()
229
+ except httpx.TimeoutException:
230
+ raise PQAuthError("Request timed out", "TIMEOUT")
231
+ except httpx.NetworkError as exc:
232
+ raise PQAuthError(f"Network error: {exc}", "NETWORK_ERROR")
233
+ return HealthResult(
234
+ status=data.get("status", ""),
235
+ algorithm=data.get("algorithm", ""),
236
+ quantumResistant=data.get("quantumResistant", False),
237
+ version=data.get("version", ""),
238
+ )
fipsign/client.py ADDED
@@ -0,0 +1,443 @@
1
+ """
2
+ PQAuth — main client class.
3
+
4
+ Mirrors the JavaScript fipsign-sdk PQAuth class 1:1 in method names,
5
+ behaviour, and error semantics. All I/O is synchronous (requests library).
6
+ For async usage see the async_client module (httpx-based).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import requests
15
+ from requests.exceptions import ConnectionError, Timeout
16
+
17
+ from .errors import PQAuthError
18
+ from .types import (
19
+ HealthResult,
20
+ MonthlyEntry,
21
+ PackEntry,
22
+ PQToken,
23
+ RevokeResult,
24
+ SignMeta,
25
+ SignResult,
26
+ SignUsage,
27
+ UsageCurrent,
28
+ UsageResult,
29
+ VerifyResult,
30
+ WebhookGetResult,
31
+ WebhookResult,
32
+ )
33
+ from .webhooks import Webhooks
34
+
35
+ DEFAULT_BASE_URL = "https://api.fipsign.dev"
36
+ DEFAULT_TIMEOUT = 10 # seconds
37
+
38
+
39
+ class PQAuth:
40
+ """
41
+ FIPSign post-quantum signing client.
42
+
43
+ Parameters
44
+ ----------
45
+ api_key : str
46
+ Your FIPSign API key. Must start with ``pqa_``.
47
+ Get one at https://app.fipsign.dev
48
+ base_url : str, optional
49
+ Override the API base URL (useful for self-hosted instances).
50
+ Defaults to https://api.fipsign.dev
51
+ timeout : int | float, optional
52
+ Request timeout in seconds. Default: 10.
53
+ session : requests.Session, optional
54
+ Supply a custom requests Session (e.g. for custom TLS or proxies).
55
+
56
+ Raises
57
+ ------
58
+ PQAuthError(code="INVALID_API_KEY")
59
+ Raised immediately in the constructor if the key doesn't start with ``pqa_``.
60
+
61
+ Examples
62
+ --------
63
+ Simple form — just the API key:
64
+
65
+ >>> pq = PQAuth("pqa_your_api_key")
66
+
67
+ All options:
68
+
69
+ >>> pq = PQAuth(
70
+ ... api_key="pqa_your_api_key",
71
+ ... base_url="https://api.fipsign.dev",
72
+ ... timeout=10,
73
+ ... )
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ api_key: str,
79
+ *,
80
+ base_url: str = DEFAULT_BASE_URL,
81
+ timeout: float = DEFAULT_TIMEOUT,
82
+ session: Optional[requests.Session] = None,
83
+ ) -> None:
84
+ if not api_key or not api_key.startswith("pqa_"):
85
+ raise PQAuthError(
86
+ 'Invalid API key — keys must start with "pqa_". '
87
+ "Get one at https://app.fipsign.dev",
88
+ "INVALID_API_KEY",
89
+ )
90
+ self._api_key = api_key
91
+ self._base_url = base_url.rstrip("/")
92
+ self._timeout = timeout
93
+ self._session = session or requests.Session()
94
+ self._session.headers.update(
95
+ {
96
+ "Content-Type": "application/json",
97
+ "X-API-Key": self._api_key,
98
+ }
99
+ )
100
+ self.webhooks = Webhooks(self)
101
+
102
+ # ── Private: HTTP wrapper ─────────────────────────────────────────────────
103
+
104
+ def _request(
105
+ self,
106
+ method: str,
107
+ path: str,
108
+ *,
109
+ json: Optional[Dict[str, Any]] = None,
110
+ ) -> Dict[str, Any]:
111
+ url = f"{self._base_url}{path}"
112
+ try:
113
+ resp = self._session.request(
114
+ method,
115
+ url,
116
+ json=json,
117
+ timeout=self._timeout,
118
+ )
119
+ except Timeout:
120
+ raise PQAuthError("Request timed out", "TIMEOUT")
121
+ except ConnectionError as exc:
122
+ raise PQAuthError(f"Network error: {exc}", "NETWORK_ERROR")
123
+ except Exception as exc:
124
+ raise PQAuthError(f"Network error: {exc}", "NETWORK_ERROR")
125
+
126
+ try:
127
+ data = resp.json()
128
+ except ValueError:
129
+ raise PQAuthError(
130
+ f"Request failed with status {resp.status_code}",
131
+ "API_ERROR",
132
+ resp.status_code,
133
+ )
134
+
135
+ if not resp.ok or not data.get("success", False):
136
+ raise PQAuthError(
137
+ data.get("error") or f"Request failed with status {resp.status_code}",
138
+ "API_ERROR",
139
+ resp.status_code,
140
+ )
141
+
142
+ return data
143
+
144
+ # ── sign() ────────────────────────────────────────────────────────────────
145
+
146
+ def sign(self, sub: str, *, expires_in_seconds: Optional[int] = None, **fields: Any) -> SignResult:
147
+ """
148
+ Sign any payload with ML-DSA-65.
149
+
150
+ The only required argument is ``sub`` — any string identifying the entity:
151
+ a user, an order, a document, a device, an event, anything.
152
+ All extra keyword arguments are stored in the payload and returned on verify.
153
+
154
+ Cost: 1 token.
155
+
156
+ Parameters
157
+ ----------
158
+ sub : str
159
+ Required. Entity identifier. Max 128 characters.
160
+ expires_in_seconds : int, optional
161
+ Token lifetime in seconds. Default: 3600 (1 hour).
162
+ Pass ``None`` or omit for non-expiring tokens (document signatures).
163
+ **fields
164
+ Any additional custom fields (max 10; string values max 256 chars).
165
+
166
+ Returns
167
+ -------
168
+ SignResult
169
+ .token — PQToken (pass to verify() / revoke())
170
+ .meta — algorithm, standard, expiresIn, tokenCost, source, …
171
+ .usage — freeRemaining, packRemaining, totalRemaining, month
172
+
173
+ Raises
174
+ ------
175
+ PQAuthError(code="MISSING_SUB")
176
+ If sub is empty.
177
+ PQAuthError(code="API_ERROR", status=400)
178
+ If more than 10 custom fields are provided, or field values exceed limits.
179
+ PQAuthError(code="API_ERROR", status=429)
180
+ If rate limit or token quota is exceeded.
181
+
182
+ Examples
183
+ --------
184
+ >>> result = pq.sign("user_123", email="user@example.com", role="admin", expires_in_seconds=3600)
185
+ >>> result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
186
+ >>> result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
187
+ """
188
+ if not sub:
189
+ raise PQAuthError('"sub" is required', "MISSING_SUB")
190
+
191
+ body: Dict[str, Any] = {"sub": sub, **fields}
192
+ if expires_in_seconds is not None:
193
+ body["expiresInSeconds"] = expires_in_seconds
194
+
195
+ data = self._request("POST", "/sign", json=body)
196
+
197
+ t = data["token"]
198
+ m = data["meta"]
199
+ u = data["usage"]
200
+
201
+ return SignResult(
202
+ token=PQToken(
203
+ payload=t["payload"],
204
+ signature=t["signature"],
205
+ algorithm=t["algorithm"],
206
+ issuedAt=t["issuedAt"],
207
+ ),
208
+ meta=SignMeta(
209
+ algorithm=m["algorithm"],
210
+ standard=m["standard"],
211
+ quantumResistant=m["quantumResistant"],
212
+ expiresIn=m["expiresIn"],
213
+ issuedFor=m["issuedFor"],
214
+ projectId=m["projectId"],
215
+ tokenCost=m["tokenCost"],
216
+ source=m["source"],
217
+ ),
218
+ usage=SignUsage(
219
+ freeRemaining=u["freeRemaining"],
220
+ packRemaining=u["packRemaining"],
221
+ totalRemaining=u["totalRemaining"],
222
+ month=u["month"],
223
+ ),
224
+ )
225
+
226
+ # ── verify() ──────────────────────────────────────────────────────────────
227
+
228
+ def verify(self, token: PQToken) -> VerifyResult:
229
+ """
230
+ Verify a FIPSign token.
231
+
232
+ **Never raises.** Returns a VerifyResult with ``valid=False`` and an
233
+ ``error`` message on any failure (invalid signature, expired, revoked,
234
+ network error, etc.).
235
+
236
+ Checks: ML-DSA-65 signature · token expiry · revocation list.
237
+
238
+ Cost: 1 token.
239
+
240
+ Parameters
241
+ ----------
242
+ token : PQToken
243
+ The token returned by sign().
244
+
245
+ Returns
246
+ -------
247
+ VerifyResult
248
+ .valid — True if the token is valid
249
+ .payload — decoded payload dict (sub, iat, exp + custom fields)
250
+ .error — error message string when valid=False
251
+
252
+ Examples
253
+ --------
254
+ >>> result = pq.verify(token)
255
+ >>> if not result.valid:
256
+ ... raise PermissionError(result.error)
257
+ >>> user_id = result.payload["sub"]
258
+ """
259
+ try:
260
+ data = self._request(
261
+ "POST",
262
+ "/verify",
263
+ json={"token": token.to_dict()},
264
+ )
265
+ return VerifyResult(valid=True, payload=data.get("payload"))
266
+ except PQAuthError as exc:
267
+ return VerifyResult(valid=False, error=exc.message)
268
+ except Exception as exc:
269
+ return VerifyResult(valid=False, error=str(exc))
270
+
271
+ # ── revoke() ──────────────────────────────────────────────────────────────
272
+
273
+ def revoke(self, token: PQToken, reason: Optional[str] = None) -> RevokeResult:
274
+ """
275
+ Immediately and permanently revoke a token.
276
+
277
+ Future verify() calls will reject it even if the signature is still
278
+ valid and the token has not expired.
279
+
280
+ Revoking an already-revoked token returns success without consuming
281
+ an extra token — the operation is idempotent.
282
+
283
+ Cost: 1 token.
284
+
285
+ Parameters
286
+ ----------
287
+ token : PQToken
288
+ The token to revoke.
289
+ reason : str, optional
290
+ Human-readable reason stored server-side (e.g. "user logged out").
291
+
292
+ Returns
293
+ -------
294
+ RevokeResult
295
+ .success, .message, .revokedAt, .sub, .expiresAt, .note
296
+
297
+ Raises
298
+ ------
299
+ PQAuthError(code="API_ERROR", status=400)
300
+ If the token is already expired (expired tokens cannot be revoked).
301
+
302
+ Examples
303
+ --------
304
+ >>> pq.revoke(token, "user logged out")
305
+ >>> pq.revoke(token, "suspicious activity detected")
306
+ """
307
+ body: Dict[str, Any] = {"token": token.to_dict()}
308
+ if reason is not None:
309
+ body["reason"] = reason
310
+
311
+ data = self._request("POST", "/revoke", json=body)
312
+ return RevokeResult(
313
+ success=data.get("success", False),
314
+ message=data.get("message", ""),
315
+ revokedAt=data.get("revokedAt"),
316
+ sub=data.get("sub"),
317
+ expiresAt=data.get("expiresAt"),
318
+ note=data.get("note"),
319
+ )
320
+
321
+ # ── usage() ───────────────────────────────────────────────────────────────
322
+
323
+ def usage(self) -> UsageResult:
324
+ """
325
+ Get current token balance and 6-month usage history.
326
+
327
+ No token cost.
328
+
329
+ Free tokens reset on the 1st of each month (UTC). Unused free tokens
330
+ do not carry over. Pack tokens never expire and accumulate across
331
+ purchases. All projects under the same account share a single pool.
332
+
333
+ Returns
334
+ -------
335
+ UsageResult
336
+ .current — month, freeUsed, freeRemaining, freeLimit, packRemaining, totalRemaining
337
+ .monthly_history — list of 6 MonthlyEntry (oldest → newest)
338
+ .packs — list of PackEntry for purchased packs
339
+ .developer — {"email": "..."}
340
+ .note — informational string
341
+
342
+ Examples
343
+ --------
344
+ >>> u = pq.usage()
345
+ >>> print(f"{u.current.freeRemaining} / {u.current.freeLimit} free tokens remaining")
346
+ >>> for entry in u.monthly_history:
347
+ ... print(f"{entry.month}: {entry.tokensUsed} used")
348
+ """
349
+ data = self._request("GET", "/usage")
350
+ c = data["current"]
351
+ return UsageResult(
352
+ current=UsageCurrent(
353
+ month=c["month"],
354
+ freeUsed=c["freeUsed"],
355
+ freeRemaining=c["freeRemaining"],
356
+ freeLimit=c["freeLimit"],
357
+ packRemaining=c["packRemaining"],
358
+ totalRemaining=c["totalRemaining"],
359
+ ),
360
+ monthlyHistory=[
361
+ MonthlyEntry(
362
+ month=e["month"],
363
+ tokensUsed=e["tokensUsed"],
364
+ fromFree=e["fromFree"],
365
+ fromPack=e["fromPack"],
366
+ )
367
+ for e in data.get("monthlyHistory", [])
368
+ ],
369
+ packs=[
370
+ PackEntry(
371
+ id=p["id"],
372
+ packType=p["packType"],
373
+ tokensPurchased=p["tokensPurchased"],
374
+ purchasedAt=p["purchasedAt"],
375
+ paymentRef=p.get("paymentRef"),
376
+ )
377
+ for p in data.get("packs", [])
378
+ ],
379
+ developer=data.get("developer", {}),
380
+ note=data.get("note", ""),
381
+ )
382
+
383
+ # ── preload_public_key() ──────────────────────────────────────────────────
384
+
385
+ def preload_public_key(self) -> str:
386
+ """
387
+ Fetch and return the server's ML-DSA-65 public key.
388
+
389
+ The FIPSign Python SDK performs all verification server-side, so this
390
+ method is provided for interoperability — e.g. if you want to verify
391
+ tokens locally in Python using a third-party ML-DSA-65 library.
392
+
393
+ Returns
394
+ -------
395
+ str
396
+ Base64-encoded ML-DSA-65 public key.
397
+
398
+ Examples
399
+ --------
400
+ >>> pub_key_b64 = pq.preload_public_key()
401
+ """
402
+ resp = self._session.get(
403
+ f"{self._base_url}/public-key",
404
+ timeout=self._timeout,
405
+ )
406
+ data = resp.json()
407
+ return data["publicKey"]
408
+
409
+ # ── health() ──────────────────────────────────────────────────────────────
410
+
411
+ def health(self) -> HealthResult:
412
+ """
413
+ Check the health of the FIPSign service.
414
+
415
+ Public endpoint — no API key required, no token cost.
416
+
417
+ Returns
418
+ -------
419
+ HealthResult
420
+ .status ("ok"), .algorithm ("ML-DSA-65"), .quantumResistant (True), .version
421
+
422
+ Examples
423
+ --------
424
+ >>> h = pq.health()
425
+ >>> assert h.status == "ok"
426
+ """
427
+ try:
428
+ resp = self._session.get(
429
+ f"{self._base_url}/health",
430
+ timeout=self._timeout,
431
+ )
432
+ data = resp.json()
433
+ except Timeout:
434
+ raise PQAuthError("Request timed out", "TIMEOUT")
435
+ except ConnectionError as exc:
436
+ raise PQAuthError(f"Network error: {exc}", "NETWORK_ERROR")
437
+
438
+ return HealthResult(
439
+ status=data.get("status", ""),
440
+ algorithm=data.get("algorithm", ""),
441
+ quantumResistant=data.get("quantumResistant", False),
442
+ version=data.get("version", ""),
443
+ )