88This is the SPAR-local instance of the shared ``PyJWTCryptoHelper`` design
99(see the OpenG2P platform docs). It is kept here for now; the strategic plan is to
1010move it into ``openg2p-fastapi-common`` ``partner_auth`` so every product shares
11- one implementation — at which point SPAR swaps the one-line registration in
12- ``app.py``.
11+ one implementation — at which point the product swaps the one-line registration in
12+ ``app.py``. See ``docs/local-signature-verification.md``.
1313
1414* Inbound ``verify_jwt`` — verifies a detached JWS (``header..signature``) sent by
15- a partner in the ``Signature`` header, against the partner's public key looked up
16- by ``km_ref_id`` (e.g. ``PARTNER_<MNEMONIC>``). The G2P Bridge calls SPAR with
17- ``sender_app_mnemonic = g2p_bridge`` → key ``PARTNER_G2P_BRIDGE``.
18- * Outbound ``create_jwt_token`` — signs a payload with the service's own private
19- key and returns a detached JWS.
15+ a partner in the ``Signature`` header, against the partner's public certificate
16+ looked up by ``km_ref_id`` (e.g. ``PARTNER_<MNEMONIC>``) in the DB-backed
17+ ``PartnerKeyStore``.
18+ * Outbound ``create_jwt_token`` — signs a payload with this service's own private
19+ key, loaded from a password-protected **PKCS#12 (.p12)** keystore, and returns a
20+ detached JWS for downstream services (e.g. SPAR).
21+
22+ Key material:
23+ * Private signing key — a PKCS#12 (.p12) file (private key + self-signed cert),
24+ unlocked with a password. This is the standard secure on-disk format; the .p12 is
25+ mounted from a Secret and its password supplied from a Secret.
26+ * Partner public keys — X.509 certificates (PEM) held in the ``partner_keys`` table.
2027
2128Signing input matches the established wire contract:
2229``base64url(protected_header)`` + ``.`` + ``base64url(canonical_json(body))`` where
2835import logging
2936
3037import orjson
31- from jwt import PyJWK , PyJWS
38+ from cryptography .hazmat .primitives import hashes
39+ from cryptography .hazmat .primitives .serialization import load_pem_public_key , pkcs12
40+ from cryptography .x509 import load_pem_x509_certificate
41+ from jwt import PyJWS
3242from openg2p_fastapi_common .utils .crypto import CryptoHelper
3343
3444from .constants import (
@@ -45,8 +55,9 @@ class PyJWTCryptoHelper(CryptoHelper):
4555 def __init__ (
4656 self ,
4757 * ,
48- partner_keys_dir = None ,
58+ partner_key_store = None ,
4959 signing_key_path = None ,
60+ signing_key_password = None ,
5061 signing_key_kid = None ,
5162 signing_algorithm = DEFAULT_SIGNING_ALGORITHM ,
5263 allowed_algorithms = None ,
@@ -57,14 +68,17 @@ def __init__(
5768 self .allowed_algorithms = tuple (
5869 allowed_algorithms or DEFAULT_ALLOWED_ALGORITHMS
5970 )
71+ # DB-backed partner public-cert store; a default (engine-derived) store is
72+ # used when none is injected.
6073 self ._partner_key_store = (
61- PartnerKeyStore ( partner_keys_dir ) if partner_keys_dir else None
74+ partner_key_store if partner_key_store is not None else PartnerKeyStore ()
6275 )
6376 self ._signing_key_path = signing_key_path
77+ self ._signing_key_password = signing_key_password
6478 self ._signing_key_kid = signing_key_kid
6579 self ._signing_algorithm = signing_algorithm
6680 self ._signing_key = None # lazy-loaded cryptography private key
67- self ._signing_kid = None # kid read from the signing JWK on load
81+ self ._signing_kid = None # kid derived from the signing cert on load
6882 self ._jws = PyJWS ()
6983
7084 async def aclose (self ):
@@ -99,7 +113,7 @@ async def verify_jwt(
99113 if self ._partner_key_store is None :
100114 _logger .error ("Partner key store not configured; cannot verify signature" )
101115 return False
102- keys = self ._partner_key_store .get_keys (km_ref_id )
116+ keys = await self ._partner_key_store .get_keys (km_ref_id )
103117 if not keys :
104118 _logger .error ("No registered keys for partner '%s'" , km_ref_id )
105119 return False
@@ -121,9 +135,9 @@ async def verify_jwt(
121135 else :
122136 verifiable = f"{ part1 } .{ self ._b64u (self ._canonical (payload ))} .{ part3 } "
123137
124- for jwk in candidates :
138+ for entry in candidates :
125139 try :
126- key = PyJWK . from_dict ( jwk ). key
140+ key = self . _load_public_key ( entry [ "public_key_pem" ])
127141 self ._jws .decode (verifiable , key , algorithms = [alg ])
128142 except Exception :
129143 continue
@@ -164,24 +178,41 @@ def _load_signing_key(self):
164178 return self ._signing_key
165179 if not self ._signing_key_path :
166180 raise ValueError ("Signing key path not configured; cannot create JWS" )
167- with open (self ._signing_key_path , encoding = "utf-8" ) as handle :
168- jwk = orjson .loads (handle .read ())
169- self ._signing_kid = jwk .get ("kid" )
170- self ._signing_key = PyJWK .from_dict (jwk ).key
181+ with open (self ._signing_key_path , "rb" ) as handle :
182+ data = handle .read ()
183+ password = (
184+ self ._signing_key_password .encode () if self ._signing_key_password else None
185+ )
186+ private_key , cert , _extra = pkcs12 .load_key_and_certificates (data , password )
187+ if private_key is None :
188+ raise ValueError ("PKCS#12 keystore contains no private key" )
189+ # kid: prefer an explicitly configured one, else the cert's SHA-256
190+ # thumbprint (so the verifier can correlate it to the onboarded cert).
191+ if cert is not None :
192+ self ._signing_kid = self ._b64u (cert .fingerprint (hashes .SHA256 ()))
193+ self ._signing_key = private_key
171194 return self ._signing_key
172195
196+ @staticmethod
197+ def _load_public_key (pem ):
198+ """Build a public key from a PEM cert (preferred) or bare public-key PEM."""
199+ pem_bytes = pem .encode () if isinstance (pem , str ) else pem
200+ if b"BEGIN CERTIFICATE" in pem_bytes :
201+ return load_pem_x509_certificate (pem_bytes ).public_key ()
202+ return load_pem_public_key (pem_bytes )
203+
173204 def _candidate_keys (self , keys , header , alg ):
174205 """Keys eligible to verify this header: matching kid (if present) and a
175206 registered alg that is consistent with the header alg."""
176207 kid = header .get ("kid" )
177208 candidates = []
178- for jwk in keys :
179- if kid and jwk .get ("kid" ) and jwk .get ("kid" ) != kid :
209+ for entry in keys :
210+ if kid and entry .get ("kid" ) and entry .get ("kid" ) != kid :
180211 continue
181- key_alg = jwk .get ("alg " )
212+ key_alg = entry .get ("algorithm " )
182213 if key_alg and key_alg != alg :
183214 continue
184- candidates .append (jwk )
215+ candidates .append (entry )
185216 return candidates
186217
187218 def _is_algorithm_allowed (self , alg ):
0 commit comments