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.
@@ -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
+ [![CI](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/ci.yml/badge.svg)](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/ci.yml)
32
+ [![Tests](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/tests.yml/badge.svg)](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/tests.yml)
33
+ [![codecov](https://codecov.io/gh/CompliantInnovation/docspera-hmac-signing-lib/branch/master/graph/badge.svg)](https://codecov.io/gh/CompliantInnovation/docspera-hmac-signing-lib)
34
+ [![Release](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/release.yml/badge.svg)](https://github.com/CompliantInnovation/docspera-hmac-signing-lib/actions/workflows/release.yml)
35
+ [![PyPI version](https://badge.fury.io/py/docspera-hmac-signing-lib.svg)](https://badge.fury.io/py/docspera-hmac-signing-lib)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/docspera-hmac-signing-lib.svg)](https://pypi.org/project/docspera-hmac-signing-lib/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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