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 +14 -0
- fipsign/async_client.py +238 -0
- fipsign/client.py +443 -0
- fipsign/errors.py +36 -0
- fipsign/middleware.py +234 -0
- fipsign/py.typed +0 -0
- fipsign/types.py +178 -0
- fipsign/webhooks.py +119 -0
- fipsign_sdk-0.5.2.dist-info/METADATA +414 -0
- fipsign_sdk-0.5.2.dist-info/RECORD +13 -0
- fipsign_sdk-0.5.2.dist-info/WHEEL +5 -0
- fipsign_sdk-0.5.2.dist-info/licenses/LICENSE +21 -0
- fipsign_sdk-0.5.2.dist-info/top_level.txt +1 -0
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"
|
fipsign/async_client.py
ADDED
|
@@ -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
|
+
)
|