This document covers the cryptographic primitives used by the auth service.
ES256 (ECDSA P-256)
All JWTs are signed using ES256 (ECDSA with the P-256 curve). The implementation uses PyJWT with the cryptography backend.
Key Generation
from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1, generate_private_key
private_key = generate_private_key(SECP256R1())
Keys are generated as PEM-encoded strings:
- Public key:
SubjectPublicKeyInfo format, cached in-memory by the API’s TokenValidator
- Private key:
PKCS8 format, encrypted at rest before storage
JWT Structure
{
"header": { "alg": "ES256", "typ": "JWT" },
"payload": {
"jti": "unique-token-id",
"sub": "customer-uuid",
"typ": "agent",
"iat": 1708905600,
"exp": 1708992000,
"parent_jti": "bearer-jti",
"agent_id": "code-review-agent",
"rbac": {}
}
}
Why ES256 over RS256:
- 256-bit keys vs 2048+ bit (smaller JWTs)
- ~0.5ms verification vs ~1-2ms
- Aligned with WebAuthn / FIDO2 ecosystem
AES-256-GCM (Private Key Encryption)
Private signing keys are encrypted at rest using AES-256-GCM before storage in auth.signing_keys.encrypted_private_key.
Encryption Flow
master_key (env var: AUTH_MASTER_KEY)
│
▼ SHA-256
256-bit AES key
│
▼ AES-256-GCM
encrypted = base64(nonce[12] + ciphertext + tag[16])
│
▼ stored in DB
auth.signing_keys.encrypted_private_key
Implementation
def encrypt_private_key(private_key_pem: str) -> str:
key = hashlib.sha256(master_key.encode()).digest() # 256-bit
nonce = os.urandom(12) # 96-bit nonce
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, pem.encode(), None)
return base64.b64encode(nonce + ciphertext).decode()
Nonce: 12 bytes (96 bits), generated with os.urandom(). Unique per encryption operation.
Tag: 16 bytes (128 bits), appended to ciphertext by GCM mode. Provides authenticated encryption — tampering is detected on decryption.
The AUTH_MASTER_KEY is provided as an environment variable. In production, source it from a secrets manager (Railway secrets, AWS Secrets Manager, etc.). Never commit it to version control.
Bloom Filter (Revocation)
A Redis bitmap bloom filter provides O(1) token revocation checks.
Parameters
| Parameter | Value | Rationale |
|---|
| Size (m) | 1,000,000 bits (~122 KB) | Fits in Redis memory |
| Hash count (k) | 7 | Optimal for expected load |
| Expected items (n) | 100,000 | Projected revoked tokens |
| False positive rate (p) | ~0.8% | Acceptable for revocation |
Hash Function: Double Hashing
digest = SHA-256(jti)
h1 = uint64(digest[0:8]) # First 8 bytes as unsigned 64-bit int
h2 = uint64(digest[8:16]) # Next 8 bytes
positions = [(h1 + i * h2) % m for i in range(k)]
This produces k=7 bit positions from a single SHA-256 hash, avoiding the need for k independent hash functions.
Operations
Add (on revocation):
For each position: Redis SETBIT bloom_key position 1
Check (on validation):
For each position: Redis GETBIT bloom_key position
If ALL bits are 1 → possibly revoked (reject token)
If ANY bit is 0 → definitely not revoked (allow)
Rebuild (after Redis restart):
1. DELETE bloom_key
2. SELECT jti FROM auth.revocation_log
3. For each jti: SETBIT for all positions
Uses PIPELINE for batch operations to minimize Redis round-trips.
False Positive Impact
A false positive (~0.8% chance) causes a valid, non-revoked token to be rejected with TokenRevokedError. The impact is minimal:
- Client receives 401 and re-authenticates
- If persistent, the customer can trigger a bloom filter rebuild
- The false positive rate is well below the 1% threshold
No false negatives: If a token is revoked, the bloom filter will always detect it.
HMAC-SHA256 (Action Signatures)
Action signatures provide non-repudiation — they cryptographically bind an agent to the action it performed.
Signature Creation
message = "{agent_jti}|{action}|{target_resource}|{timestamp}|{nonce}"
signature = HMAC-SHA256(secret, message)
| Field | Source |
|---|
agent_jti | From the authenticated agent token |
action | The event action being performed |
target_resource | The resource being accessed |
timestamp | ISO 8601 UTC timestamp |
nonce | 16 bytes of secrets.token_hex() |
Verification
- Check timestamp freshness (max age: 5 minutes)
- Reject future timestamps
- Recompute HMAC from claims
- Constant-time comparison with
hmac.compare_digest()
Override Co-Signatures
Override decisions are attested with a separate HMAC co-signature:
message = "override|{event_id}|{decision}|{override_jti}"
cosignature = HMAC-SHA256(override_secret, message)
This creates a cryptographic proof that a specific override token was used to approve or reject a specific event.
Webhook Signing
Outbound webhook payloads are signed so customers can verify authenticity:
message = "{timestamp}.{json_payload}"
signature = HMAC-SHA256(webhook_secret, message)
Header format:
X-Quint-Signature: t={timestamp},v1={hex_signature}
Customers verify by:
- Extracting
timestamp and signature from header
- Checking timestamp is within tolerance (e.g., 5 minutes)
- Computing
HMAC-SHA256(their_secret, "{timestamp}.{payload}")
- Comparing with
hmac.compare_digest()