Skip to content

Commit 46dc46b

Browse files
committed
G2P-5209 Partner public keys stored in DB.
1 parent 6ab35c4 commit 46dc46b

12 files changed

Lines changed: 351 additions & 128 deletions

File tree

core/mapper-partner-api/src/openg2p_spar_mapper_partner_api/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
MapperService,
1515
RequestValidation,
1616
)
17-
from openg2p_spar_models.crypto import PyJWTCryptoHelper
18-
from openg2p_spar_models.models import IdFaMapping, Strategy
17+
from openg2p_spar_models.crypto import PyJWTCryptoHelper, seed_partner_certs
18+
from openg2p_spar_models.models import IdFaMapping, PartnerKey, Strategy
1919

2020
from .controllers import MapperController
2121

@@ -33,7 +33,6 @@ def initialize(self, **kwargs):
3333
ResponseHelper()
3434
JWTValidationHelper()
3535
PyJWTCryptoHelper(
36-
partner_keys_dir=_config.partner_keys_dir,
3736
allowed_algorithms=[
3837
alg.strip()
3938
for alg in _config.signature_allowed_algorithms.split(",")
@@ -50,5 +49,9 @@ async def migrate():
5049
_logger.info("Migrating database")
5150
await IdFaMapping.create_migrate()
5251
await Strategy.create_migrate()
52+
await PartnerKey.create_migrate()
53+
# Seed-based partner onboarding: upsert configured partner certs into
54+
# the partner_keys table (idempotent).
55+
await seed_partner_certs(_config.partner_certs)
5356

5457
asyncio.run(migrate())

core/mapper-partner-api/src/openg2p_spar_mapper_partner_api/config.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ class Settings(BaseSettings):
2828
# Inbound partner JWS signature verification (local, Keymanager-free).
2929
# SPAR_MAPPER_PARTNER_API_JWT_AUTH_ENABLED — when false, signature validation
3030
# is skipped entirely. When true, each request must carry a detached JWS in the
31-
# "Signature" header, verified against the partner's public key loaded from
32-
# partner_keys_dir (one JWKS file per partner, named PARTNER_<MNEMONIC>.json).
31+
# "Signature" header, verified against the partner's public certificate held in
32+
# the partner_keys table (DB-backed; see openg2p_spar_models.crypto).
3333
# The G2P Bridge calls SPAR as sender_app_mnemonic=g2p_bridge -> PARTNER_G2P_BRIDGE.
3434
jwt_auth_enabled: bool = False
35-
partner_keys_dir: str = "/etc/spar/partner-keys"
3635
# Comma-separated allowed JWS algorithms. RS256 only (asymmetric); "none" and
3736
# HMAC (HS*) are always rejected regardless of this list.
3837
signature_allowed_algorithms: str = "RS256"
38+
# Seed-based partner onboarding: a JSON list of partner certs upserted into the
39+
# partner_keys table at migrate-time. Each item:
40+
# {"reference_id": "PARTNER_<MNEMONIC>", "public_key": "<PEM cert>",
41+
# "kid": "<optional>", "algorithm": "RS256"}
42+
# (Runtime admin-API onboarding is a planned follow-up — see docs.)
43+
partner_certs: list[dict] = []

core/models/src/openg2p_spar_models/crypto/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
)
66
from .key_store import PartnerKeyStore
77
from .pyjwt_crypto_helper import PyJWTCryptoHelper
8+
from .seed import seed_partner_certs
89

910
__all__ = [
1011
"PyJWTCryptoHelper",
1112
"PartnerKeyStore",
13+
"seed_partner_certs",
1214
"DEFAULT_ALLOWED_ALGORITHMS",
1315
"DEFAULT_SIGNING_ALGORITHM",
1416
"is_forbidden_algorithm",
Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,111 @@
1-
"""Filesystem-backed store of partner public keys for inbound JWS verification.
2-
3-
This replaces the remote Keymanager certificate lookup. Each partner gets one
4-
JWKS file named ``<reference_id>.json`` inside ``keys_dir`` — e.g.
5-
``PARTNER_MY_PSP.json`` for reference id ``PARTNER_MY_PSP`` (the value
6-
``JWTValidationHelper`` derives from ``sender_app_mnemonic``). Each file is a
7-
standard JWKS (``{"keys": [ ... ]}``) holding one or more public keys, each with
8-
a ``kid`` and ``alg``.
9-
10-
Multiple keys per partner make rotation an overlap operation: register the new
11-
key, let the partner switch signing to it, then drop the old key — no coordinated
12-
cutover, because lookup is by ``kid``. The directory is normally mounted from a
13-
Kubernetes Secret/ConfigMap; files are cached and reloaded automatically when
14-
their mtime changes.
1+
"""DB-backed store of partner public keys for inbound JWS verification.
2+
3+
Partner public certificates live in the ``partner_keys`` table (see
4+
``..models.partner_key.PartnerKey``). On each inbound verify we look up the
5+
active, currently-valid certs for a ``reference_id`` (``PARTNER_<MNEMONIC>``) and
6+
return them as lightweight dicts (``kid`` / ``algorithm`` / ``public_key_pem``) so
7+
the crypto helper stays library-agnostic.
8+
9+
Results are cached per ``reference_id`` for a short TTL so a high request rate does
10+
not hit the database on every signature check; rotation/revocation propagate within
11+
one TTL window. A ``session_maker`` may be injected (tests); otherwise one is built
12+
lazily from the process-wide async engine.
1513
"""
1614

17-
import json
1815
import logging
19-
import os
2016
import threading
17+
import time
18+
from datetime import datetime
19+
20+
from sqlalchemy import select
2121

2222
_logger = logging.getLogger("openg2p_spar_models.crypto.key_store")
2323

24+
DEFAULT_CACHE_TTL_SECONDS = 300
25+
2426

2527
class PartnerKeyStore:
26-
def __init__(self, keys_dir):
27-
self.keys_dir = keys_dir
28-
self._cache = {} # reference_id -> (mtime, list[jwk dict])
28+
def __init__(
29+
self,
30+
session_maker=None,
31+
cache_ttl_seconds=DEFAULT_CACHE_TTL_SECONDS,
32+
clock=time.monotonic,
33+
):
34+
self._session_maker = session_maker
35+
self._ttl = cache_ttl_seconds
36+
self._clock = clock
37+
self._cache = {} # reference_id -> (expiry_monotonic, list[dict])
2938
self._lock = threading.Lock()
3039

31-
def _path_for(self, reference_id):
32-
# reference_id is an opaque token like PARTNER_MY_PSP. Reject anything
33-
# that could escape keys_dir.
34-
if (
35-
not reference_id
36-
or "/" in reference_id
37-
or os.sep in reference_id
38-
or ".." in reference_id
39-
):
40-
return None
41-
return os.path.join(self.keys_dir, f"{reference_id}.json")
40+
def _maker(self):
41+
if self._session_maker is not None:
42+
return self._session_maker
43+
# Build lazily from the process-wide engine so this works before the app
44+
# has finished wiring the DB at import time.
45+
from openg2p_fastapi_common.context import dbengine
46+
from sqlalchemy.ext.asyncio import async_sessionmaker
4247

43-
def get_keys(self, reference_id):
44-
"""Return the partner's JWK dicts (a JWKS ``keys`` list), or None if unknown."""
45-
if not self.keys_dir:
46-
return None
47-
path = self._path_for(reference_id)
48-
if not path or not os.path.isfile(path):
49-
_logger.warning("No partner key file for reference id '%s'", reference_id)
48+
return async_sessionmaker(dbengine.get(), expire_on_commit=False)
49+
50+
async def get_keys(self, reference_id):
51+
"""Return active partner key dicts for ``reference_id``, or None if unknown.
52+
53+
Each dict: ``{"kid": str|None, "algorithm": str, "public_key_pem": str}``.
54+
"""
55+
if not reference_id:
5056
return None
51-
mtime = os.path.getmtime(path)
57+
now = self._clock()
5258
with self._lock:
5359
cached = self._cache.get(reference_id)
54-
if cached and cached[0] == mtime:
55-
return cached[1]
60+
if cached and cached[0] > now:
61+
return cached[1] or None
62+
5663
try:
57-
with open(path, encoding="utf-8") as handle:
58-
jwks = json.load(handle)
59-
keys = jwks.get("keys") if isinstance(jwks, dict) else None
60-
if not isinstance(keys, list) or not keys:
61-
_logger.error("Partner JWKS for '%s' has no 'keys' array", reference_id)
62-
return None
64+
from ..models.partner_key import PartnerKey
65+
66+
maker = self._maker()
67+
async with maker() as session:
68+
result = await session.execute(
69+
select(PartnerKey).where(
70+
PartnerKey.reference_id == reference_id,
71+
PartnerKey.status == "active",
72+
)
73+
)
74+
rows = result.scalars().all()
6375
except Exception:
64-
_logger.exception("Failed to load partner JWKS for '%s'", reference_id)
76+
_logger.exception("Failed to load partner keys for '%s'", reference_id)
6577
return None
78+
79+
wall_now = datetime.now()
80+
keys = []
81+
for row in rows:
82+
if row.valid_from and row.valid_from > wall_now:
83+
continue
84+
if row.valid_to and row.valid_to < wall_now:
85+
continue
86+
if not row.public_key:
87+
continue
88+
keys.append(
89+
{
90+
"kid": row.kid,
91+
"algorithm": row.algorithm or "RS256",
92+
"public_key_pem": row.public_key,
93+
}
94+
)
95+
6696
with self._lock:
67-
self._cache[reference_id] = (mtime, keys)
97+
self._cache[reference_id] = (now + self._ttl, keys)
98+
if not keys:
99+
_logger.warning(
100+
"No active partner keys for reference id '%s'", reference_id
101+
)
102+
return None
68103
return keys
104+
105+
def invalidate(self, reference_id=None):
106+
"""Drop cached keys (all, or one reference_id) — e.g. after onboarding."""
107+
with self._lock:
108+
if reference_id is None:
109+
self._cache.clear()
110+
else:
111+
self._cache.pop(reference_id, None)

core/models/src/openg2p_spar_models/crypto/pyjwt_crypto_helper.py

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@
88
This 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
1010
move 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
2128
Signing input matches the established wire contract:
2229
``base64url(protected_header)`` + ``.`` + ``base64url(canonical_json(body))`` where
@@ -28,7 +35,10 @@
2835
import logging
2936

3037
import 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
3242
from openg2p_fastapi_common.utils.crypto import CryptoHelper
3343

3444
from .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

Comments
 (0)