docspera-hmac-signing-lib 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docspera_hmac_signing_lib-0.1.0/PKG-INFO +630 -0
- docspera_hmac_signing_lib-0.1.0/README.md +602 -0
- docspera_hmac_signing_lib-0.1.0/docspera_hmac_signing_lib.egg-info/PKG-INFO +630 -0
- docspera_hmac_signing_lib-0.1.0/docspera_hmac_signing_lib.egg-info/SOURCES.txt +14 -0
- docspera_hmac_signing_lib-0.1.0/docspera_hmac_signing_lib.egg-info/dependency_links.txt +1 -0
- docspera_hmac_signing_lib-0.1.0/docspera_hmac_signing_lib.egg-info/requires.txt +10 -0
- docspera_hmac_signing_lib-0.1.0/docspera_hmac_signing_lib.egg-info/top_level.txt +1 -0
- docspera_hmac_signing_lib-0.1.0/hmac_lib/__init__.py +92 -0
- docspera_hmac_signing_lib-0.1.0/hmac_lib/asymmetric.py +332 -0
- docspera_hmac_signing_lib-0.1.0/hmac_lib/hmac_lib.py +377 -0
- docspera_hmac_signing_lib-0.1.0/hmac_lib/key_manager.py +453 -0
- docspera_hmac_signing_lib-0.1.0/pyproject.toml +80 -0
- docspera_hmac_signing_lib-0.1.0/setup.cfg +4 -0
- docspera_hmac_signing_lib-0.1.0/tests/test_asymmetric.py +442 -0
- docspera_hmac_signing_lib-0.1.0/tests/test_hmac_lib.py +469 -0
- docspera_hmac_signing_lib-0.1.0/tests/test_key_manager.py +543 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docspera-hmac-signing-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HMAC and asymmetric signing library for HTTP webhook requests
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 4 - Beta
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: cryptography>=41.0.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
24
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: bandit>=1.7.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# HMAC Signing Library
|
|
30
|
+
|
|
31
|
+
[](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/tests.yml)
|
|
33
|
+
[](https://codecov.io/gh/CompliantInnovation/docspera-hmac-signing-lib)
|
|
34
|
+
[](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/release.yml)
|
|
35
|
+
[](https://badge.fury.io/py/docspera-hmac-signing-lib)
|
|
36
|
+
[](https://pypi.org/project/docspera-hmac-signing-lib/)
|
|
37
|
+
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
|
|
39
|
+
A Python library for signing and verifying HTTP webhook requests between systems. Supports HMAC (symmetric) and asymmetric (Ed25519/RSA) signing with built-in key rotation support.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or add to your `requirements.txt`:
|
|
48
|
+
```
|
|
49
|
+
git+https://github.com/your-org/docspera-hmac-signing-lib.git
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- **HMAC Signing** - Sign requests with a shared secret key
|
|
55
|
+
- **Asymmetric Signing** - Sign with private key, verify with public key (Ed25519 or RSA)
|
|
56
|
+
- **Key Rotation** - Support multiple valid keys simultaneously for zero-downtime rotation
|
|
57
|
+
- **Timestamp Validation** - Prevent replay attacks with configurable time windows
|
|
58
|
+
- **Thread-Safe** - Key manager is safe for concurrent use
|
|
59
|
+
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
### HMAC Signing (Shared Secret)
|
|
63
|
+
|
|
64
|
+
**Client - Sign a request:**
|
|
65
|
+
```python
|
|
66
|
+
from hmac_lib import create_signed_request
|
|
67
|
+
import requests
|
|
68
|
+
|
|
69
|
+
body = '{"event": "order.created", "data": {"id": 123}}'
|
|
70
|
+
|
|
71
|
+
headers = create_signed_request(
|
|
72
|
+
body=body,
|
|
73
|
+
secret_key="your-shared-secret",
|
|
74
|
+
credential="your-api-key",
|
|
75
|
+
method="POST",
|
|
76
|
+
path="/webhook",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
response = requests.post(
|
|
80
|
+
"https://api.example.com/webhook",
|
|
81
|
+
data=body,
|
|
82
|
+
headers=headers,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Server - Verify a request (AWS Lambda / API Gateway):**
|
|
87
|
+
```python
|
|
88
|
+
from hmac_lib import validate_hmac_signature
|
|
89
|
+
|
|
90
|
+
def lambda_handler(event, context):
|
|
91
|
+
result = validate_hmac_signature(event, secret_key="your-shared-secret")
|
|
92
|
+
|
|
93
|
+
if result is not True:
|
|
94
|
+
return result # Returns {"statusCode": 401, "body": "..."}
|
|
95
|
+
|
|
96
|
+
# Process the valid request
|
|
97
|
+
return {"statusCode": 200, "body": "OK"}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Server - Verify a request (generic):**
|
|
101
|
+
```python
|
|
102
|
+
from hmac_lib import verify_hmac_signature
|
|
103
|
+
|
|
104
|
+
is_valid, error = verify_hmac_signature(
|
|
105
|
+
body=request.body,
|
|
106
|
+
secret_key="your-shared-secret",
|
|
107
|
+
auth_header=request.headers["Authorization"],
|
|
108
|
+
headers=dict(request.headers),
|
|
109
|
+
method="POST",
|
|
110
|
+
path="/webhook",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if not is_valid:
|
|
114
|
+
return Response(status=401, body=f"Unauthorized: {error}")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Asymmetric Signing (Public/Private Keys)
|
|
118
|
+
|
|
119
|
+
Use asymmetric signing when you want to:
|
|
120
|
+
- Share only the public key with the verifying party
|
|
121
|
+
- Prove the sender's identity (non-repudiation)
|
|
122
|
+
- Avoid sharing secrets between systems
|
|
123
|
+
|
|
124
|
+
**Generate keys (do once, store securely):**
|
|
125
|
+
```python
|
|
126
|
+
from hmac_lib import generate_key_pair, KeyType
|
|
127
|
+
|
|
128
|
+
# Ed25519 (recommended - fast, small keys)
|
|
129
|
+
private_key, public_key = generate_key_pair(KeyType.ED25519)
|
|
130
|
+
|
|
131
|
+
# Or RSA (for legacy compatibility)
|
|
132
|
+
private_key, public_key = generate_key_pair(KeyType.RSA, key_size=2048)
|
|
133
|
+
|
|
134
|
+
# Save keys to files
|
|
135
|
+
with open("private_key.pem", "wb") as f:
|
|
136
|
+
f.write(private_key)
|
|
137
|
+
with open("public_key.pem", "wb") as f:
|
|
138
|
+
f.write(public_key)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Client - Sign with private key:**
|
|
142
|
+
```python
|
|
143
|
+
from hmac_lib import create_signed_request_asymmetric, KeyType
|
|
144
|
+
|
|
145
|
+
with open("private_key.pem", "rb") as f:
|
|
146
|
+
private_key = f.read()
|
|
147
|
+
|
|
148
|
+
body = '{"event": "order.created"}'
|
|
149
|
+
|
|
150
|
+
headers = create_signed_request_asymmetric(
|
|
151
|
+
body=body,
|
|
152
|
+
private_key_pem=private_key,
|
|
153
|
+
key_id="client-key-v1", # Required - identifies which key was used
|
|
154
|
+
key_type=KeyType.ED25519,
|
|
155
|
+
method="POST",
|
|
156
|
+
path="/webhook",
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Server - Verify with public key:**
|
|
161
|
+
```python
|
|
162
|
+
from hmac_lib import verify_asymmetric_signature, parse_asymmetric_header
|
|
163
|
+
|
|
164
|
+
with open("client_public_key.pem", "rb") as f:
|
|
165
|
+
public_key = f.read()
|
|
166
|
+
|
|
167
|
+
# Parse the Authorization header
|
|
168
|
+
auth_type, params = parse_asymmetric_header(request.headers["Authorization"])
|
|
169
|
+
|
|
170
|
+
# Extract signed headers
|
|
171
|
+
signed_headers = {}
|
|
172
|
+
for name in params["signed_headers"].split(";"):
|
|
173
|
+
signed_headers[name] = request.headers.get(name)
|
|
174
|
+
|
|
175
|
+
# Verify
|
|
176
|
+
is_valid, error = verify_asymmetric_signature(
|
|
177
|
+
body=request.body,
|
|
178
|
+
public_key_pem=public_key,
|
|
179
|
+
signature=params["signature"],
|
|
180
|
+
key_type=params["key_type"],
|
|
181
|
+
headers_to_sign=signed_headers,
|
|
182
|
+
method="POST",
|
|
183
|
+
path="/webhook",
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Key Rotation with KeyManager
|
|
188
|
+
|
|
189
|
+
The `KeyManager` class handles multiple keys for seamless rotation:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from hmac_lib import KeyManager, SigningMethod
|
|
193
|
+
|
|
194
|
+
km = KeyManager()
|
|
195
|
+
|
|
196
|
+
# Phase 1: Add initial key
|
|
197
|
+
km.add_hmac_key("v1", "secret-key-v1")
|
|
198
|
+
|
|
199
|
+
# Phase 2: Add new key (both valid for verification)
|
|
200
|
+
km.add_hmac_key("v2", "secret-key-v2")
|
|
201
|
+
|
|
202
|
+
# Phase 3: Switch to new key for signing
|
|
203
|
+
km.set_active_key("v2")
|
|
204
|
+
|
|
205
|
+
# Sign requests (uses active key v2)
|
|
206
|
+
headers = km.sign_request(
|
|
207
|
+
body='{"data": "value"}',
|
|
208
|
+
method="POST",
|
|
209
|
+
path="/webhook",
|
|
210
|
+
)
|
|
211
|
+
# Authorization header includes KeyId=v2
|
|
212
|
+
|
|
213
|
+
# Verify requests (works with both v1 and v2)
|
|
214
|
+
is_valid, error = km.verify_request(
|
|
215
|
+
body=request_body,
|
|
216
|
+
auth_header=request.headers["Authorization"],
|
|
217
|
+
headers=dict(request.headers),
|
|
218
|
+
method="POST",
|
|
219
|
+
path="/webhook",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Phase 4: Remove old key after transition
|
|
223
|
+
km.remove_key("v1")
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Mixed key types:**
|
|
227
|
+
```python
|
|
228
|
+
from hmac_lib import KeyManager, SigningMethod, generate_key_pair, KeyType
|
|
229
|
+
|
|
230
|
+
km = KeyManager()
|
|
231
|
+
|
|
232
|
+
# Add HMAC key
|
|
233
|
+
km.add_hmac_key("hmac-1", "shared-secret")
|
|
234
|
+
|
|
235
|
+
# Add asymmetric key
|
|
236
|
+
private_key, public_key = generate_key_pair(KeyType.ED25519)
|
|
237
|
+
km.add_asymmetric_key(
|
|
238
|
+
"ed25519-1",
|
|
239
|
+
SigningMethod.ED25519,
|
|
240
|
+
private_key_pem=private_key,
|
|
241
|
+
public_key_pem=public_key,
|
|
242
|
+
set_active=True,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Sign with asymmetric key (active)
|
|
246
|
+
headers = km.sign_request(body='{"data": "value"}')
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Verification-only keys (server side):**
|
|
250
|
+
```python
|
|
251
|
+
# Server only needs public keys to verify
|
|
252
|
+
km = KeyManager()
|
|
253
|
+
km.add_asymmetric_key(
|
|
254
|
+
"client-key-1",
|
|
255
|
+
SigningMethod.ED25519,
|
|
256
|
+
public_key_pem=client_public_key, # No private key needed
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Authorization Header Format
|
|
261
|
+
|
|
262
|
+
### HMAC
|
|
263
|
+
```
|
|
264
|
+
Authorization: HMAC-SHA256 KeyId=key-v1&Credential=api-key&SignedHeaders=date;host&Signature=base64sig
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Asymmetric
|
|
268
|
+
```
|
|
269
|
+
Authorization: ASYMMETRIC-Ed25519 KeyId=key-v1&SignedHeaders=date;host&Signature=base64sig
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Required fields:**
|
|
273
|
+
- `KeyId` - Identifies which key was used (required for all requests)
|
|
274
|
+
- `SignedHeaders` - Semicolon-separated list of headers included in signature
|
|
275
|
+
- `Signature` - Base64-encoded signature
|
|
276
|
+
|
|
277
|
+
## Canonical String Format
|
|
278
|
+
|
|
279
|
+
The signature is computed over a canonical string:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
METHOD
|
|
283
|
+
PATH
|
|
284
|
+
header1:value1
|
|
285
|
+
header2:value2
|
|
286
|
+
BODY
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Headers are sorted alphabetically by name (case-insensitive).
|
|
290
|
+
|
|
291
|
+
## API Reference
|
|
292
|
+
|
|
293
|
+
### HMAC Functions
|
|
294
|
+
|
|
295
|
+
| Function | Description |
|
|
296
|
+
|----------|-------------|
|
|
297
|
+
| `create_signed_request()` | Create signed request headers |
|
|
298
|
+
| `validate_hmac_signature()` | Validate API Gateway event (returns True or error dict) |
|
|
299
|
+
| `verify_hmac_signature()` | Verify signature (returns tuple of is_valid, error) |
|
|
300
|
+
| `compute_hmac_signature()` | Compute raw signature |
|
|
301
|
+
| `parse_hmac_header()` | Parse Authorization header |
|
|
302
|
+
| `verify_timestamp()` | Validate Date header timestamp |
|
|
303
|
+
|
|
304
|
+
### Asymmetric Functions
|
|
305
|
+
|
|
306
|
+
| Function | Description |
|
|
307
|
+
|----------|-------------|
|
|
308
|
+
| `generate_key_pair()` | Generate Ed25519 or RSA key pair |
|
|
309
|
+
| `create_signed_request_asymmetric()` | Create signed request headers |
|
|
310
|
+
| `verify_asymmetric_signature()` | Verify signature with public key |
|
|
311
|
+
| `compute_asymmetric_signature()` | Compute raw signature with private key |
|
|
312
|
+
| `parse_asymmetric_header()` | Parse Authorization header |
|
|
313
|
+
|
|
314
|
+
### Key Manager
|
|
315
|
+
|
|
316
|
+
| Method | Description |
|
|
317
|
+
|--------|-------------|
|
|
318
|
+
| `add_hmac_key()` | Add HMAC key |
|
|
319
|
+
| `add_asymmetric_key()` | Add asymmetric key pair |
|
|
320
|
+
| `set_active_key()` | Set key for signing new requests |
|
|
321
|
+
| `remove_key()` | Remove a key (cannot remove active key) |
|
|
322
|
+
| `mark_key_invalid()` | Mark key as invalid for verification |
|
|
323
|
+
| `sign_request()` | Sign request with active key |
|
|
324
|
+
| `verify_request()` | Verify request (finds key by KeyId) |
|
|
325
|
+
| `list_keys()` | List all keys with status |
|
|
326
|
+
|
|
327
|
+
## Manual Implementation (Without Library)
|
|
328
|
+
|
|
329
|
+
If you need to implement signing in another language or without this library, here's how to create a compatible signature:
|
|
330
|
+
|
|
331
|
+
### Python Example (Manual HMAC Signing)
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
import base64
|
|
335
|
+
import hashlib
|
|
336
|
+
import hmac
|
|
337
|
+
from email.utils import formatdate
|
|
338
|
+
import requests
|
|
339
|
+
|
|
340
|
+
# Configuration
|
|
341
|
+
secret_key = "your-shared-secret"
|
|
342
|
+
key_id = "your-key-id"
|
|
343
|
+
credential = "your-api-key"
|
|
344
|
+
method = "POST"
|
|
345
|
+
path = "/webhook"
|
|
346
|
+
url = f"https://api.example.com{path}"
|
|
347
|
+
body = '{"event":"order.created","data":{"id":123}}'
|
|
348
|
+
|
|
349
|
+
# Step 1: Create headers to sign
|
|
350
|
+
date_header = formatdate(usegmt=True) # e.g., "Wed, 05 Feb 2026 12:00:00 GMT"
|
|
351
|
+
headers_to_sign = {
|
|
352
|
+
"date": date_header,
|
|
353
|
+
"host": "api.example.com",
|
|
354
|
+
"content-type": "application/json",
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# Step 2: Build canonical string
|
|
358
|
+
# Format: METHOD\nPATH\nheader1:value1\nheader2:value2\n...\nBODY
|
|
359
|
+
# Headers must be sorted alphabetically (case-insensitive)
|
|
360
|
+
canonical_parts = [method, path]
|
|
361
|
+
for header_name in sorted(headers_to_sign.keys(), key=str.lower):
|
|
362
|
+
canonical_parts.append(f"{header_name.lower()}:{headers_to_sign[header_name]}")
|
|
363
|
+
canonical_parts.append(body)
|
|
364
|
+
canonical_string = "\n".join(canonical_parts)
|
|
365
|
+
|
|
366
|
+
# Step 3: Compute HMAC-SHA256 signature
|
|
367
|
+
signature_bytes = hmac.new(
|
|
368
|
+
secret_key.encode("utf-8"),
|
|
369
|
+
canonical_string.encode("utf-8"),
|
|
370
|
+
hashlib.sha256,
|
|
371
|
+
).digest()
|
|
372
|
+
signature = base64.b64encode(signature_bytes).decode("ascii")
|
|
373
|
+
|
|
374
|
+
# Step 4: Build Authorization header
|
|
375
|
+
signed_headers_list = ";".join(sorted(headers_to_sign.keys(), key=str.lower))
|
|
376
|
+
auth_header = f"HMAC-SHA256 KeyId={key_id}&Credential={credential}&SignedHeaders={signed_headers_list}&Signature={signature}"
|
|
377
|
+
|
|
378
|
+
# Step 5: Make the request
|
|
379
|
+
response = requests.post(
|
|
380
|
+
url,
|
|
381
|
+
data=body,
|
|
382
|
+
headers={
|
|
383
|
+
"Authorization": auth_header,
|
|
384
|
+
"Date": date_header,
|
|
385
|
+
"Host": "api.example.com",
|
|
386
|
+
"Content-Type": "application/json",
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
print(f"Response: {response.status_code}")
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Canonical String Example
|
|
393
|
+
|
|
394
|
+
For a POST request to `/webhook` with body `{"event":"test"}`:
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
POST
|
|
398
|
+
/webhook
|
|
399
|
+
content-type:application/json
|
|
400
|
+
date:Wed, 05 Feb 2026 12:00:00 GMT
|
|
401
|
+
host:api.example.com
|
|
402
|
+
{"event":"test"}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Other Languages
|
|
406
|
+
|
|
407
|
+
The algorithm is straightforward to implement in any language:
|
|
408
|
+
|
|
409
|
+
1. **Build canonical string**: `METHOD + \n + PATH + \n + sorted_headers + \n + BODY`
|
|
410
|
+
2. **Compute signature**: `base64(HMAC-SHA256(secret_key, canonical_string))`
|
|
411
|
+
3. **Format header**: `HMAC-SHA256 KeyId=...&Credential=...&SignedHeaders=...&Signature=...`
|
|
412
|
+
|
|
413
|
+
**Key points:**
|
|
414
|
+
- Headers are sorted alphabetically by lowercase name
|
|
415
|
+
- Header format in canonical string: `lowercase_name:value` (no space after colon)
|
|
416
|
+
- SignedHeaders is semicolon-separated, lowercase, alphabetically sorted
|
|
417
|
+
- Signature is base64-encoded
|
|
418
|
+
|
|
419
|
+
## FastAPI JWKS Endpoint (Public Key Distribution)
|
|
420
|
+
|
|
421
|
+
If you're using asymmetric signing with key rotation, you can expose your public keys via a standard JWKS endpoint so that clients can automatically fetch and cache verification keys.
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
pip install docspera-hmac-signing-lib fastapi uvicorn
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
import base64
|
|
429
|
+
import uuid
|
|
430
|
+
from datetime import datetime, timedelta, timezone
|
|
431
|
+
|
|
432
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_public_key
|
|
433
|
+
from fastapi import FastAPI
|
|
434
|
+
from fastapi.responses import JSONResponse
|
|
435
|
+
|
|
436
|
+
from hmac_lib import KeyManager, KeyType, SigningMethod, generate_key_pair
|
|
437
|
+
|
|
438
|
+
app = FastAPI()
|
|
439
|
+
|
|
440
|
+
# --- Key rotation config ---
|
|
441
|
+
KEY_TYPE = KeyType.ED25519
|
|
442
|
+
ROTATION_INTERVAL_HOURS = 24
|
|
443
|
+
GRACE_PERIOD_HOURS = 48
|
|
444
|
+
|
|
445
|
+
# --- State ---
|
|
446
|
+
km = KeyManager()
|
|
447
|
+
key_metadata: dict[str, dict] = {} # key_id -> {public_key_pem, created_at, expires_at}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _base64url(data: bytes) -> str:
|
|
451
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _pem_to_jwk(key_id: str, public_key_pem: bytes) -> dict:
|
|
455
|
+
"""Convert an Ed25519 or RSA PEM public key to JWK format."""
|
|
456
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
|
457
|
+
|
|
458
|
+
pub = load_pem_public_key(public_key_pem)
|
|
459
|
+
|
|
460
|
+
if isinstance(pub, ed25519.Ed25519PublicKey):
|
|
461
|
+
raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
462
|
+
return {"kty": "OKP", "crv": "Ed25519", "x": _base64url(raw),
|
|
463
|
+
"kid": key_id, "use": "sig", "alg": "EdDSA"}
|
|
464
|
+
|
|
465
|
+
if isinstance(pub, rsa.RSAPublicKey):
|
|
466
|
+
nums = pub.public_numbers()
|
|
467
|
+
n_bytes = nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")
|
|
468
|
+
e_bytes = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
|
|
469
|
+
return {"kty": "RSA", "n": _base64url(n_bytes), "e": _base64url(e_bytes),
|
|
470
|
+
"kid": key_id, "use": "sig", "alg": "RS256"}
|
|
471
|
+
|
|
472
|
+
raise ValueError(f"Unsupported key type: {type(pub)}")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def rotate_key():
|
|
476
|
+
"""Generate a new key pair, register it in KeyManager, and track metadata."""
|
|
477
|
+
key_id = f"key-{uuid.uuid4().hex[:8]}"
|
|
478
|
+
private_pem, public_pem = generate_key_pair(KEY_TYPE)
|
|
479
|
+
method = SigningMethod.ED25519 if KEY_TYPE == KeyType.ED25519 else SigningMethod.RSA
|
|
480
|
+
|
|
481
|
+
km.add_asymmetric_key(key_id, method,
|
|
482
|
+
private_key_pem=private_pem,
|
|
483
|
+
public_key_pem=public_pem,
|
|
484
|
+
set_active=True)
|
|
485
|
+
|
|
486
|
+
now = datetime.now(timezone.utc)
|
|
487
|
+
key_metadata[key_id] = {
|
|
488
|
+
"public_key_pem": public_pem,
|
|
489
|
+
"created_at": now,
|
|
490
|
+
"expires_at": now + timedelta(hours=ROTATION_INTERVAL_HOURS),
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def cleanup_expired_keys():
|
|
495
|
+
"""Remove keys that are past the grace period."""
|
|
496
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=GRACE_PERIOD_HOURS)
|
|
497
|
+
active_key = km.get_active_key()
|
|
498
|
+
for kid in list(key_metadata):
|
|
499
|
+
meta = key_metadata[kid]
|
|
500
|
+
if meta["expires_at"] < cutoff and (active_key is None or kid != active_key.key_id):
|
|
501
|
+
km.remove_key(kid)
|
|
502
|
+
del key_metadata[kid]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# Create initial key on startup
|
|
506
|
+
rotate_key()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@app.get("/.well-known/jwks.json")
|
|
510
|
+
def jwks():
|
|
511
|
+
"""Public JWKS endpoint — returns all valid public keys."""
|
|
512
|
+
cleanup_expired_keys()
|
|
513
|
+
keys = [_pem_to_jwk(kid, meta["public_key_pem"])
|
|
514
|
+
for kid, meta in key_metadata.items()]
|
|
515
|
+
return JSONResponse(
|
|
516
|
+
content={"keys": keys},
|
|
517
|
+
headers={"Cache-Control": "public, max-age=3600"},
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@app.post("/rotate")
|
|
522
|
+
def trigger_rotation():
|
|
523
|
+
"""Trigger a manual key rotation (protect this in production)."""
|
|
524
|
+
rotate_key()
|
|
525
|
+
return {"status": "rotated", "active_key": km.get_active_key().key_id}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Run locally:
|
|
529
|
+
```bash
|
|
530
|
+
uvicorn app:app --reload
|
|
531
|
+
# GET http://localhost:8000/.well-known/jwks.json
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
**Clients** verify signatures by fetching the JWKS endpoint, finding the key matching the `KeyId` from the `Authorization` header, and using it to verify:
|
|
535
|
+
|
|
536
|
+
```python
|
|
537
|
+
import requests
|
|
538
|
+
from hmac_lib import verify_asymmetric_signature, parse_asymmetric_header
|
|
539
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
540
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
541
|
+
|
|
542
|
+
def verify_with_jwks(jwks_url, auth_header, body, headers, method="POST", path="/"):
|
|
543
|
+
"""Fetch public keys from JWKS endpoint and verify the request signature."""
|
|
544
|
+
_, params = parse_asymmetric_header(auth_header)
|
|
545
|
+
kid = params["key_id"]
|
|
546
|
+
|
|
547
|
+
# Fetch JWKS (cache this in production)
|
|
548
|
+
jwks = requests.get(jwks_url).json()
|
|
549
|
+
jwk = next((k for k in jwks["keys"] if k["kid"] == kid), None)
|
|
550
|
+
if not jwk:
|
|
551
|
+
return False, f"Key {kid} not found in JWKS"
|
|
552
|
+
|
|
553
|
+
# Reconstruct PEM from JWK
|
|
554
|
+
if jwk["kty"] == "OKP" and jwk["crv"] == "Ed25519":
|
|
555
|
+
import base64
|
|
556
|
+
raw = base64.urlsafe_b64decode(jwk["x"] + "==")
|
|
557
|
+
pub = ed25519.Ed25519PublicKey.from_public_bytes(raw)
|
|
558
|
+
public_key_pem = pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
|
|
559
|
+
else:
|
|
560
|
+
raise ValueError(f"Unsupported JWK type: {jwk['kty']}")
|
|
561
|
+
|
|
562
|
+
# Extract signed headers
|
|
563
|
+
signed_headers = {}
|
|
564
|
+
for name in params["signed_headers"].split(";"):
|
|
565
|
+
if name:
|
|
566
|
+
signed_headers[name] = headers.get(name, "")
|
|
567
|
+
|
|
568
|
+
return verify_asymmetric_signature(
|
|
569
|
+
body=body,
|
|
570
|
+
public_key_pem=public_key_pem,
|
|
571
|
+
signature=params["signature"],
|
|
572
|
+
key_type=params["key_type"],
|
|
573
|
+
headers_to_sign=signed_headers,
|
|
574
|
+
method=method,
|
|
575
|
+
path=path,
|
|
576
|
+
)
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Configuration Options
|
|
580
|
+
|
|
581
|
+
### Timestamp Validation
|
|
582
|
+
|
|
583
|
+
```python
|
|
584
|
+
# Default: 5 minutes (300 seconds)
|
|
585
|
+
validate_hmac_signature(event, secret_key, max_age_seconds=300)
|
|
586
|
+
|
|
587
|
+
# Custom time window
|
|
588
|
+
validate_hmac_signature(event, secret_key, max_age_seconds=600) # 10 minutes
|
|
589
|
+
|
|
590
|
+
# Disable timestamp validation entirely
|
|
591
|
+
validate_hmac_signature(event, secret_key, require_date=False)
|
|
592
|
+
verify_hmac_signature(..., require_date=False)
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Algorithms
|
|
596
|
+
|
|
597
|
+
**HMAC:**
|
|
598
|
+
- SHA256 (default)
|
|
599
|
+
- SHA384
|
|
600
|
+
- SHA512
|
|
601
|
+
- SHA224
|
|
602
|
+
- SHA1 (not recommended)
|
|
603
|
+
|
|
604
|
+
**Asymmetric:**
|
|
605
|
+
- Ed25519 (default, recommended)
|
|
606
|
+
- RSA with PSS padding and SHA256
|
|
607
|
+
|
|
608
|
+
## Security Considerations
|
|
609
|
+
|
|
610
|
+
1. **Timestamp validation** prevents replay attacks - requests older than 5 minutes are rejected by default
|
|
611
|
+
2. **Constant-time comparison** prevents timing attacks on signature verification
|
|
612
|
+
3. **KeyId required** - all requests must identify which key was used
|
|
613
|
+
4. **Date header must be signed** - prevents timestamp tampering
|
|
614
|
+
|
|
615
|
+
## Development
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
# Install dev dependencies
|
|
619
|
+
pip install -e ".[dev]"
|
|
620
|
+
|
|
621
|
+
# Run tests
|
|
622
|
+
pytest tests/ -v
|
|
623
|
+
|
|
624
|
+
# Run with coverage
|
|
625
|
+
pytest tests/ --cov=hmac_lib --cov-report=term-missing
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
## License
|
|
629
|
+
|
|
630
|
+
MIT
|