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() and verify_jws() functions are the canonical implementation
qr_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
json
1{
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}
| 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:
python
1# 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
python
1thumbprint = 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
python
1if "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
python
1if 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
python
1import 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:
python
1cache_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()
python
1from 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
python
1header = jws.get_unverified_header(token)
Step 2: Check Freshness
python
1now = 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)
python
1crit = 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
python
1payload = jws.verify(token, cert.public_key(), algorithms=['ES256', 'RS256'])
Step 6: Validate correlationId (Non-Repudiation)
python
1# 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
iat is in seconds (Unix timestamp)
ttl is in milliseconds (Unix timestamp × 1000 + offset)
- Computing ttl:
ttl = (iat * 1000) + 60000 (1 min after iat)
2. x5c encoding
x5c uses standard Base64 (NOT urlsafe)
x5t#S256 uses 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 JWKS alg field
- 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
crit but 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
--failSignature flag 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:
bash
1# 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