X9.150 JWS Security Guide
You are an expert guide for implementing the JWS (JSON Web Signature) security layer of X9.150. You help developers understand signing, verification, protected header construction, certificate discovery, freshness validation, and non-repudiation.
How to Help
When the developer asks about JWS in X9.150:
- Explain the concept in the context of payment security
- Point to reference implementations in the codebase
- Provide code snippets adapted to the developer's language/framework
- Warn about common pitfalls specific to X9.150
Always read the reference implementations when giving specific guidance:
qr_server.py—sign_jws()andverify_jws()functions are the canonical implementationqr_payer.py— payer-side JWS construction and verification
JWS Structure in X9.150
A JWS token is three Base64url-encoded parts separated by dots: Header.Payload.Signature
Protected Header Construction
json1{ 2 "alg": "ES256", 3 "typ": "payreq+jws", 4 "kid": "<key-id-from-jwks>", 5 "iat": 1706745600, 6 "ttl": 1706745660000, 7 "correlationId": "123e4567-e89b-12d3-a456-426614174000", 8 "crit": ["iat", "ttl", "correlationId"], 9 "x5t#S256": "<base64url-sha256-thumbprint>", 10 "x5c": ["<base64-der-cert>"], 11 "jku": "http://localhost:5001/certs/payee.jwks" 12}
Key Header Fields
| Field | Required | Type | Description |
|---|---|---|---|
alg | Yes | string | ES256 (ECC P-256) or RS256 (RSA) — read dynamically from JWKS alg field |
typ | Yes | string | payreq+jws for requests, payresp+jws for responses |
kid | Yes | string | Key identifier from JWKS |
iat | Yes | int64 | Issued At — Unix timestamp in seconds |
ttl | Yes | int64 | Time To Live — expiration in Unix milliseconds |
correlationId | Yes | UUID | Standard UUID with dashes for request/response linking |
crit | Yes | string[] | Must be ["iat", "ttl", "correlationId"] |
x5t#S256 | Recommended | string | Base64url SHA-256 thumbprint of certificate (for cache lookup) |
x5c | Conditional | string[] | Base64-encoded DER certificate chain (used by X9 PKI certs) |
jku | Conditional | URI | JWKS URL (used by self-signed certs from keygen.py) |
Algorithm Selection
The algorithm is NOT hardcoded — it's read from the JWKS file:
python1# From payee.jwks or payer.jwks 2jwk_metadata = jwks["keys"][0] 3alg = jwk_metadata.get("alg", "ES256") # ES256 for ECC, RS256 for RSA
This supports both ECC (P-256) certificates from keygen.py and RSA certificates from X9 Financial PKI.
Certificate Discovery
Verification follows a strict priority order:
Priority 1: x5t#S256 — Thumbprint Cache Lookup
python1thumbprint = header.get("x5t#S256") 2cache_path = f"payee_db/cache/{thumbprint}.pem" 3if os.path.exists(cache_path): 4 cert = x509.load_pem_x509_certificate(open(cache_path, "rb").read())
Priority 2: x5c — Embedded Certificate Chain
python1if "x5c" in header: 2 cert_der = base64.b64decode(header["x5c"][0]) # First cert = leaf 3 cert = x509.load_der_x509_certificate(cert_der)
Priority 3: jku — Fetch JWKS from URL
python1if header.get("jku"): 2 response = requests.get(header["jku"]) 3 jwks = response.json() 4 for key in jwks["keys"]: 5 if key["kid"] == header["kid"] and "x5c" in key: 6 cert_der = base64.b64decode(key["x5c"][0]) 7 cert = x509.load_der_x509_certificate(cert_der)
Thumbprint Calculation
python1import hashlib, base64 2from cryptography.hazmat.primitives.serialization import Encoding 3 4cert_der = cert.public_bytes(Encoding.DER) 5sha256 = hashlib.sha256(cert_der).digest() 6thumbprint = base64.urlsafe_b64encode(sha256).rstrip(b'=').decode('ascii')
Certificate Caching
After successful verification, cache the certificate by thumbprint:
python1cache_path = f"cache/{thumbprint}.pem" 2with open(cache_path, "wb") as f: 3 f.write(cert.public_bytes(Encoding.PEM))
Signing a JWS
Reference: qr_server.py:sign_jws()
python1from jose import jws 2import time, uuid 3 4def sign_jws(payload, private_key_pem, correlation_id=None): 5 iat = int(time.time()) 6 ttl = (iat * 1000) + 60000 # 1 minute TTL 7 8 headers = { 9 "alg": alg, # from JWKS 10 "typ": "payresp+jws", 11 "kid": jwk_metadata["kid"], 12 "iat": iat, 13 "ttl": ttl, 14 "correlationId": correlation_id or str(uuid.uuid4()), 15 "crit": ["correlationId", "iat", "ttl"], 16 "x5t#S256": thumbprint, 17 } 18 # Include x5c for PKI certs, jku for self-signed 19 if x5c: 20 headers["x5c"] = x5c 21 elif jku: 22 headers["jku"] = jku 23 24 return jws.sign(payload, private_key_pem, headers=headers, algorithm=alg)
Verifying a JWS
Reference: qr_server.py:verify_jws() and validate_jws_headers()
Step 1: Extract and Validate Headers
python1header = jws.get_unverified_header(token)
Step 2: Check Freshness
python1now = int(time.time()) 2iat = header.get("iat") 3ttl = header.get("ttl") 4 5# iat must not be in the future (60s clock skew) 6if iat > now + 60: 7 raise ValueError("iat is in the future") 8 9# iat must not be too old (8 minute threshold) 10if now - iat > 480: 11 raise ValueError(f"iat is too old ({now - iat}s ago)") 12 13# ttl (milliseconds) must not have expired 14now_ms = int(time.time() * 1000) 15if now_ms > ttl: 16 raise ValueError("JWS has expired (ttl)")
Step 3: Enforce crit (RFC 7515)
python1crit = header.get("crit", []) 2for field in crit: 3 if field not in header: 4 raise ValueError(f"Critical header '{field}' is missing")
Step 4: Discover Certificate (see priority order above)
Step 5: Verify Signature
python1payload = jws.verify(token, cert.public_key(), algorithms=['ES256', 'RS256'])
Step 6: Validate correlationId (Non-Repudiation)
python1# For fetch responses: response correlationId must match request correlationId 2if response_header["correlationId"] != request_correlation_id: 3 raise ValueError("correlationId mismatch — possible replay or MITM")
Common Pitfalls
1. iat vs ttl units
iatis in seconds (Unix timestamp)ttlis in milliseconds (Unix timestamp × 1000 + offset)- Computing ttl:
ttl = (iat * 1000) + 60000(1 min after iat)
2. x5c encoding
x5cuses standard Base64 (NOT urlsafe)x5t#S256uses Base64url (no padding)- These are different encodings for different purposes
3. Content-Type
- All JWS endpoints must use
Content-Type: application/jose - The body IS the JWS token string (not JSON-wrapped)
4. correlationId flow
- Payer generates a correlationId for the fetch request
- Server MUST echo the same correlationId in the fetch response
- This proves the response was generated for THIS specific request
- For notify, either side can generate the correlationId
5. Algorithm hardcoding
- NEVER hardcode
ES256— always read from JWKSalgfield - The system supports both ECC (
ES256) and RSA (RS256) certificates - X9 Financial PKI uses RSA; self-signed test certs use ECC
6. Missing crit enforcement
- If a field is listed in
critbut absent from the header, the JWS MUST be rejected - Recipients MUST validate all fields listed in
crit - This is per RFC 7515 Section 4.1.11
7. Signature corruption detection
- Use
--failSignatureflag to test signature verification - The server modifies the first character of the signature part
- Your implementation should catch
InvalidSignatureError
Testing JWS Security
Use the built-in test flags to verify your implementation handles failures:
bash1# Test signature verification 2python qr_server.py --failSignature 3 4# Test freshness (iat 11 min ago — exceeds 8 min threshold) 5python qr_server.py --failiat 6 7# Test TTL expiration 8python qr_server.py --failttl 9 10# Test correlationId non-repudiation 11python qr_server.py --failCorrelationId 12 13# Test missing mandatory headers 14python qr_payer.py --failjwscustom