From 131e3382022c57220f585f94a821b5560190efb6 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Thu, 26 Sep 2024 09:28:58 +0200 Subject: [PATCH 01/13] wip: ideas, brainstoming, etc --- pyeudiw/openid4vp/nuovo_proposta.py | 35 +++++++++++ pyeudiw/openid4vp/vp_sd_jwt_kb.py | 2 +- pyeudiw/satosa/default/openid4vp_backend.py | 13 ++++- pyeudiw/satosa/default/response_handler.py | 12 ++++ pyeudiw/trust/default/direct_trust.py | 29 +++++++++ pyeudiw/trust/default/federation.py | 65 +++++++++++++++++++++ pyeudiw/trust/default/x509.py | 6 ++ pyeudiw/trust/interface.py | 7 +++ pyeudiw/vci/__init__.py | 0 pyeudiw/vci/jwks_provider.py | 59 +++++++++++++++++++ 10 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 pyeudiw/openid4vp/nuovo_proposta.py create mode 100644 pyeudiw/trust/default/direct_trust.py create mode 100644 pyeudiw/trust/default/federation.py create mode 100644 pyeudiw/trust/default/x509.py create mode 100644 pyeudiw/trust/interface.py create mode 100644 pyeudiw/vci/__init__.py create mode 100644 pyeudiw/vci/jwks_provider.py diff --git a/pyeudiw/openid4vp/nuovo_proposta.py b/pyeudiw/openid4vp/nuovo_proposta.py new file mode 100644 index 00000000..0e798f36 --- /dev/null +++ b/pyeudiw/openid4vp/nuovo_proposta.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass + +from sd_jwt.verifier import SDJWTVerifier + +from pyeudiw.openid4vp.vp_sd_jwt_kb import VerifierChallenge +from pyeudiw.trust.interface import IssuerTrustModel + + +class IdeaVpVerifier: + def get_verified_credential(self, token: str) -> dict: + raise NotImplementedError + + # def get_issuer(self, token: str) -> dict: + # raise NotImplementedError + + +@dataclass +class IdeaNuovoVpVerifier(IdeaVpVerifier): + trust_model: IssuerTrustModel + challenge: VerifierChallenge + + def get_verified_credentials(self, vc_sdjwt: str) -> dict: + # implementazione minimale + verifier = SDJWTVerifier( + vc_sdjwt, + self.trust_model.get_verified_key, + self.challenge.aud, + self.challenge.nonce + ) + return verifier.get_verified_payload() + + +class EmptyVpVerifier: + def get_verified_credential(self, token: str) -> dict: + return {} diff --git a/pyeudiw/openid4vp/vp_sd_jwt_kb.py b/pyeudiw/openid4vp/vp_sd_jwt_kb.py index edd755ec..1c20d4f0 100644 --- a/pyeudiw/openid4vp/vp_sd_jwt_kb.py +++ b/pyeudiw/openid4vp/vp_sd_jwt_kb.py @@ -140,7 +140,7 @@ def _verify_jws_with_key(issuer_jwt: str, issuer_key: JWK): def _verify_kb_jwt(kbjwt: UnverfiedJwt, cnf_jwk: JWK, challenge: VerifierChallenge) -> None: _verify_kb_jwt_payload_challenge(kbjwt.payload, challenge) _verify_kb_jwt_payload_iat(kbjwt.payload) - # TODO: sd-jwt-python already does this check, however it would be space for us to have it more explicit in our code + # TODO: sd-jwt-python already does this check, however it would be space for us to have it more explicit in our code # _verify_kb_jwt_payload_sd_hash(sdjwt) _verify_kb_jwt_signature(kbjwt.jwt, cnf_jwk) diff --git a/pyeudiw/satosa/default/openid4vp_backend.py b/pyeudiw/satosa/default/openid4vp_backend.py index 5108beea..0595e262 100644 --- a/pyeudiw/satosa/default/openid4vp_backend.py +++ b/pyeudiw/satosa/default/openid4vp_backend.py @@ -18,6 +18,7 @@ from pyeudiw.storage.exceptions import StorageWriteError from pyeudiw.tools.mobile import is_smartphone from pyeudiw.tools.utils import iat_now +from pyeudiw.trust.interface import IssuerTrustModel from ..interfaces.openid4vp_backend import OpenID4VPBackendInterface @@ -95,12 +96,22 @@ def __init__( self._log_warning("OpenID4VPBackend", debug_message) self.response_code_helper = ResponseCodeSource(self.config["response_code"]["sym_key"]) - + self.issuer_trust_model: IssuerTrustModel = self._trust_model_factory() self._log_debug( "OpenID4VP init", f"loaded configuration: {json.dumps(config)}" ) + def _trust_model_factory(self) -> IssuerTrustModel: + """Questa funzione eroga uno (o più?) Issuer Trust Model basandosi sulle configurazioni dell'applicativo. + """ + # TODO: leggi le configurationi trust e implementa una funzione di dynamic backend load. + # È aperto il problema su come fare dependancy injection verso queste classi: una idea + # semplice è standardizzare il costruttore. Ho l'impressione che sto abusando du un factory + # pattern senza avere un idoneo framework di dependency injection (tipo Spring Core, per dire) + # e questo potrebbe compromettere la leggibilità del codice. + raise NotImplementedError + def register_endpoints(self) -> list[tuple[str, Callable[[Context], Response]]]: """ Creates a list of all the endpoints this backend module needs to listen to. In this case diff --git a/pyeudiw/satosa/default/response_handler.py b/pyeudiw/satosa/default/response_handler.py index 1a6b5d7a..97d43c5f 100644 --- a/pyeudiw/satosa/default/response_handler.py +++ b/pyeudiw/satosa/default/response_handler.py @@ -11,6 +11,7 @@ from pyeudiw.openid4vp.authorization_response import AuthorizeResponseDirectPost, AuthorizeResponsePayload from pyeudiw.openid4vp.exceptions import InvalidVPToken, KIDNotFound +from pyeudiw.openid4vp.nuovo_proposta import IdeaVpVerifier from pyeudiw.openid4vp.utils import infer_vp_iss, infer_vp_typ, infer_vp_header_claim from pyeudiw.openid4vp.vp import SUPPORTED_VC_TYPES, Vp from pyeudiw.openid4vp.vp_mock import MockVpVerifier @@ -181,6 +182,11 @@ def response_endpoint(self, context: Context, *args: tuple) -> Redirect | JsonRe credential_issuers: list[str] = [] encoded_vps: list[str] = [authz_payload.vp_token] if isinstance(authz_payload.vp_token, str) else authz_payload.vp_token for vp_token in encoded_vps: + # -- START HERE -- + nuovo_verifier: IdeaVpVerifier = self._vp_verifier_factory(authz_payload.presentation_submission) + verified_claims = nuovo_verifier.get_verified_credential() + _ = verified_claims # consuma le credenziali + # -- END HERE -- # simplified algorithm steps # (a): verify that vp is vc+sd-jwt # (b): verify that issuer jwt is valid (ok signature, not expired, etc.) @@ -329,3 +335,9 @@ def _translate_response(self, response: dict, issuer: str, context: Context) -> ) internal_resp.subject_id = sub return internal_resp + + def _vp_verifier_factory(self, presentation_submission: dict) -> IdeaVpVerifier: + # Idea: se vc+sd-jwt → IdeaNuovoVpVerifier + # PROBLEM: come faccio a fare dependency injection (in questo caso, della challenge?) + # ci devo pensare con calma, non sono sicurissimo di saperlo ora + raise NotImplementedError diff --git a/pyeudiw/trust/default/direct_trust.py b/pyeudiw/trust/default/direct_trust.py new file mode 100644 index 00000000..2e252862 --- /dev/null +++ b/pyeudiw/trust/default/direct_trust.py @@ -0,0 +1,29 @@ +from jwcrypto.jwk import JWK + +from pyeudiw.trust.interface import IssuerTrustModel +from pyeudiw.vci.jwks_provider import VciJwksSource + + +class DirectTrustModel(IssuerTrustModel): + + def __init__(self, issuer_jwks_provider: VciJwksSource): + self.issuer_jwks_provider = issuer_jwks_provider + pass + + def get_verified_key(self, issuer: str, token_header: dict) -> JWK: + kid: str = token_header.get("kid", None) + if not kid: + raise ValueError("missing claim [kid] in token header") + jwks = self.issuer_jwks_provider.get_jwks(issuer) # TODO: handle exception + issuer_keys: list[dict] = jwks.get("keys", []) + found_jwks: list[dict] = [] + for key in issuer_keys: + obt_kid: str = key.get("kid", "") + if kid == obt_kid: + found_jwks.append(key) + if len(found_jwks) != 1: + raise ValueError(f"unable to uniquely identify a key with kid {kid} in appropriate section of issuer entity configuration") + try: + return JWK(**found_jwks[0]) + except Exception as e: + raise ValueError(f"unable to parse issuer jwk: {e}") diff --git a/pyeudiw/trust/default/federation.py b/pyeudiw/trust/default/federation.py new file mode 100644 index 00000000..9c302d4b --- /dev/null +++ b/pyeudiw/trust/default/federation.py @@ -0,0 +1,65 @@ +from jwcrypto.jwk import JWK + +from pyeudiw.federation.policy import TrustChainPolicy +from pyeudiw.jwt.utils import decode_jwt_payload +from pyeudiw.trust.interface import IssuerTrustModel + + +class FederationTrustModel(IssuerTrustModel): + _ISSUER_METADATA_TYPE = "openid_credential_issuer" + + def __init__(self): + # TODO; qui c'è dentro tutta la ciccia: trust chain verification, root of trust, etc + self.metadata_policy_resolver = TrustChainPolicy() + pass + + def _verify_trust_chain(self, trust_chain: list[str]): + # TODO: qui c'è tutta la ciccia, ma si può fare copia incolla da terze parti (specialmente di pyeudiw.trust.__init__) + raise NotImplementedError + + def get_verified_key(self, issuer: str, token_header: dict) -> JWK: + # (1) verifica trust chain + kid: str = token_header.get("kid", None) + if not kid: + raise ValueError("missing claim [kid] in token header") + trust_chain: list[str] = token_header.get("trust_chain", None) + if not trust_chain: + raise ValueError("missing trust chain in federation token") + if not isinstance(trust_chain, list): + raise ValueError*("invalid format of header claim [trust_claim]") + self._verify_trust_chain(trust_chain) # TODO: check whick exceptions this might raise + + # (2) metadata parsing ed estrazione Jwk set + # TODO: wrap in something that implements VciJwksSource + # apply policy of traust anchor only? + issuer_entity_configuration = trust_chain[0] + anchor_entity_configuration = trust_chain[-1] + issuer_payload: dict = decode_jwt_payload(issuer_entity_configuration) + anchor_payload = decode_jwt_payload(anchor_entity_configuration) + trust_anchor_policy = anchor_payload.get("metadata_policy", {}) + final_issuer_metadata = self.metadata_policy_resolver.apply_policy(issuer_payload, trust_anchor_policy) + metadata: dict = final_issuer_metadata.get("metadata", None) + if not metadata: + raise ValueError("missing or invalid claim [metadata] in entity configuration") + issuer_metadata: dict = metadata.get(FederationTrustModel._ISSUER_METADATA_TYPE, None) + if not issuer_metadata: + raise ValueError(f"missing or invalid claim [metadata.{FederationTrustModel._ISSUER_METADATA_TYPE}] in entity configuration") + issuer_keys: list[dict] = issuer_metadata.get("jwks", {}).get("keys", []) + if not issuer_keys: + raise ValueError(f"missing or invalid claim [metadata.{FederationTrustModel._ISSUER_METADATA_TYPE}.jwks.keys] in entity configuration") + # check issuer = entity_id + if issuer != (obt_iss := final_issuer_metadata.get("iss", "")): + raise ValueError(f"invalid issuer metadata: expected '{issuer}', obtained '{obt_iss}'") + + # (3) dato il set completo, fa il match per kid tra l'header e il jwk set + found_jwks: list[dict] = [] + for key in issuer_keys: + obt_kid: str = key.get("kid", "") + if kid == obt_kid: + found_jwks.append(key) + if len(found_jwks) != 1: + raise ValueError(f"unable to uniquely identify a key with kid {kid} in appropriate section of issuer entity configuration") + try: + return JWK(**found_jwks[0]) + except Exception as e: + raise ValueError(f"unable to parse issuer jwk: {e}") diff --git a/pyeudiw/trust/default/x509.py b/pyeudiw/trust/default/x509.py new file mode 100644 index 00000000..f93b7801 --- /dev/null +++ b/pyeudiw/trust/default/x509.py @@ -0,0 +1,6 @@ +from pyeudiw.trust.interface import IssuerTrustModel + + +class X509TrustModel(IssuerTrustModel): + def __init__(self): + pass diff --git a/pyeudiw/trust/interface.py b/pyeudiw/trust/interface.py new file mode 100644 index 00000000..1327f12e --- /dev/null +++ b/pyeudiw/trust/interface.py @@ -0,0 +1,7 @@ +from jwcrypto.jwk import JWK + + +class IssuerTrustModel: + + def get_verified_key(issuer: str, token_header: dict) -> JWK: + raise NotImplementedError diff --git a/pyeudiw/vci/__init__.py b/pyeudiw/vci/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/vci/jwks_provider.py b/pyeudiw/vci/jwks_provider.py new file mode 100644 index 00000000..ecae31f4 --- /dev/null +++ b/pyeudiw/vci/jwks_provider.py @@ -0,0 +1,59 @@ +from functools import lru_cache +from urllib.parse import urlparse, ParseResult +import time + +from pyeudiw.tools.utils import get_http_url + +DEFAULT_ENDPOINT = "/.well-known/jwt-vc-issuer" +DEFAULT_TTL_CACHE = 60 * 60 # in seconds, hence 1 hour + + +class VciJwksSource: + """VciJwksSource is an interface that provides a jwk set for verifiable credential issuer + """ + def get_jwks(self, issuer: str) -> dict: + raise NotImplementedError + + +class RemoteVciJwksSource(VciJwksSource): + + def __init__(self, httpc_params: dict, endpoint: str = DEFAULT_ENDPOINT): + self.httpc_params = httpc_params + self.endpoint = endpoint + + def get_jwks(self, issuer: str) -> dict: + baseurl = urlparse(issuer) + well_known_path = self.endpoint + baseurl.path + well_known_uri: str = ParseResult(baseurl.scheme, baseurl.netloc, well_known_path, baseurl.params, baseurl.query, baseurl.fragment).geturl() + resp = get_http_url(well_known_uri, self.httpc_params) + resp_data: dict = resp[0].json() + if issuer != (obt_iss := resp_data.get("issuer", "")): + raise Exception(f"invalid issuing key metadata: expected issuer {issuer}, obtained {obt_iss}") + jwks = resp_data.get("jwks", None) + jwks_uri = resp_data.get("jwks_uri", None) + if (not jwks) and (not jwks_uri): + raise Exception("invalid issuing key metadata: missing both claims [jwks] and [jwks_uri]") + if not jwks: + return jwks + resp = get_http_url(jwks_uri, self.httpc_params) + return resp[0].json() + + +class CachedVciJwksSource(RemoteVciJwksSource): + + def __init__(self, ttl_cache: int = DEFAULT_TTL_CACHE, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ttl_cache = ttl_cache + + def get_jwks(self, issuer: str) -> dict: + return self._get_jwks(issuer, self._get_ttl()) + + def _get_ttl(self) -> int: + return round(time.time() / self.ttl_cache) + + @lru_cache + def _get_jwks(self, issuer: str, ttl_timestamp: int): + # TODO: check che questa cache funzioni veramente ☺: + # la cache potrebbe fallire a cuase dell'argomento self; in caso definitsci una cached_get_http_url(urls, http_params, time_to_live_timestamp) + del ttl_timestamp # this is used to a have an in-memory time based cache using the tools in the Python standard library only. + return RemoteVciJwksSource.get_jwks(self, issuer) From 290d34f7922b41f25f099a1c1093a83b7eb43487 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Thu, 26 Sep 2024 09:53:58 +0200 Subject: [PATCH 02/13] wip: intent clarification --- pyeudiw/satosa/default/openid4vp_backend.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyeudiw/satosa/default/openid4vp_backend.py b/pyeudiw/satosa/default/openid4vp_backend.py index 0595e262..c560a177 100644 --- a/pyeudiw/satosa/default/openid4vp_backend.py +++ b/pyeudiw/satosa/default/openid4vp_backend.py @@ -1,3 +1,4 @@ +import importlib import json import uuid from typing import Callable @@ -110,7 +111,11 @@ def _trust_model_factory(self) -> IssuerTrustModel: # semplice è standardizzare il costruttore. Ho l'impressione che sto abusando du un factory # pattern senza avere un idoneo framework di dependency injection (tipo Spring Core, per dire) # e questo potrebbe compromettere la leggibilità del codice. - raise NotImplementedError + first_pick_config = self.config["trust"][0] + module = importlib.import_module(first_pick_config["module"]) + ClassType = getattr(module, first_pick_config["class"]) + class_instance: IssuerTrustModel = ClassType(**first_pick_config["config"]) # NON VA BENE! Se devo fare injection di parametri non config (tipo storage), come faccio? + raise class_instance def register_endpoints(self) -> list[tuple[str, Callable[[Context], Response]]]: """ From a1c683319d84a14868df198cb2bc4c44edc27bb7 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Thu, 26 Sep 2024 16:14:07 +0200 Subject: [PATCH 03/13] wip: new trust model and vp interface --- pyeudiw/openid4vp/nuovo_proposta.py | 42 +++- pyeudiw/satosa/default/openid4vp_backend.py | 16 +- pyeudiw/satosa/default/response_handler.py | 8 +- pyeudiw/trust/_log.py | 4 + pyeudiw/trust/default/direct_trust.py | 76 ++++--- pyeudiw/trust/default/federation.py | 226 +++++++++++++++++++- pyeudiw/trust/default/x509.py | 6 +- pyeudiw/trust/dynamic.py | 40 ++++ pyeudiw/trust/exceptions.py | 4 + pyeudiw/trust/interface.py | 94 +++++++- pyeudiw/vci/jwks_provider.py | 98 ++++++--- pyeudiw/vci/utils.py | 28 +++ 12 files changed, 557 insertions(+), 85 deletions(-) create mode 100644 pyeudiw/trust/_log.py create mode 100644 pyeudiw/trust/dynamic.py create mode 100644 pyeudiw/vci/utils.py diff --git a/pyeudiw/openid4vp/nuovo_proposta.py b/pyeudiw/openid4vp/nuovo_proposta.py index 0e798f36..74f35652 100644 --- a/pyeudiw/openid4vp/nuovo_proposta.py +++ b/pyeudiw/openid4vp/nuovo_proposta.py @@ -3,23 +3,49 @@ from sd_jwt.verifier import SDJWTVerifier from pyeudiw.openid4vp.vp_sd_jwt_kb import VerifierChallenge -from pyeudiw.trust.interface import IssuerTrustModel +from pyeudiw.trust.interface import IssuerTrustEvaluator -class IdeaVpVerifier: - def get_verified_credential(self, token: str) -> dict: +class VpTokenParser: + def get_credentials(self) -> dict: + raise NotImplementedError + + def get_issuer_name(self) -> str: raise NotImplementedError - # def get_issuer(self, token: str) -> dict: - # raise NotImplementedError + def get_signing_key(self) -> dict | str: + """ + :returns: a public key or an identifier of a public key as seen in header + """ + raise NotImplementedError + + +class VpTokenVerifier: + def is_expired(self) -> bool: + raise NotImplementedError + + def is_revoked(self) -> bool: + """ + :returns: if the credential is revoked + """ + raise NotImplementedError + + def is_active(self) -> bool: + return (not self.is_expired()) and (not self.is_revoked()) + + def verify_signature(self) -> None: + """ + :raises [InvalidSignatureException]: + """ + return @dataclass -class IdeaNuovoVpVerifier(IdeaVpVerifier): - trust_model: IssuerTrustModel +class IdeaNuovoVpVerifier(VpTokenVerifier): + trust_model: IssuerTrustEvaluator challenge: VerifierChallenge - def get_verified_credentials(self, vc_sdjwt: str) -> dict: + def get_credentials(self, vc_sdjwt: str) -> dict: # implementazione minimale verifier = SDJWTVerifier( vc_sdjwt, diff --git a/pyeudiw/satosa/default/openid4vp_backend.py b/pyeudiw/satosa/default/openid4vp_backend.py index c560a177..9948532f 100644 --- a/pyeudiw/satosa/default/openid4vp_backend.py +++ b/pyeudiw/satosa/default/openid4vp_backend.py @@ -1,4 +1,3 @@ -import importlib import json import uuid from typing import Callable @@ -19,7 +18,7 @@ from pyeudiw.storage.exceptions import StorageWriteError from pyeudiw.tools.mobile import is_smartphone from pyeudiw.tools.utils import iat_now -from pyeudiw.trust.interface import IssuerTrustModel +from pyeudiw.trust.interface import IssuerTrustEvaluator from ..interfaces.openid4vp_backend import OpenID4VPBackendInterface @@ -89,7 +88,6 @@ def __init__( else self.base_url ) - self.init_trust_resources() try: PyeudiwBackendConfig(**config) except ValidationError as e: @@ -97,13 +95,13 @@ def __init__( self._log_warning("OpenID4VPBackend", debug_message) self.response_code_helper = ResponseCodeSource(self.config["response_code"]["sym_key"]) - self.issuer_trust_model: IssuerTrustModel = self._trust_model_factory() + self.issuer_trust_model: IssuerTrustEvaluator = self._trust_model_factory() self._log_debug( "OpenID4VP init", f"loaded configuration: {json.dumps(config)}" ) - def _trust_model_factory(self) -> IssuerTrustModel: + def _trust_model_factory(self) -> IssuerTrustEvaluator: """Questa funzione eroga uno (o più?) Issuer Trust Model basandosi sulle configurazioni dell'applicativo. """ # TODO: leggi le configurationi trust e implementa una funzione di dynamic backend load. @@ -111,11 +109,9 @@ def _trust_model_factory(self) -> IssuerTrustModel: # semplice è standardizzare il costruttore. Ho l'impressione che sto abusando du un factory # pattern senza avere un idoneo framework di dependency injection (tipo Spring Core, per dire) # e questo potrebbe compromettere la leggibilità del codice. - first_pick_config = self.config["trust"][0] - module = importlib.import_module(first_pick_config["module"]) - ClassType = getattr(module, first_pick_config["class"]) - class_instance: IssuerTrustModel = ClassType(**first_pick_config["config"]) # NON VA BENE! Se devo fare injection di parametri non config (tipo storage), come faccio? - raise class_instance + trust_config: dict = self.config.get("trust", {}) + trust_evaluator = IssuerTrustEvaluator(trust_config) + return trust_evaluator def register_endpoints(self) -> list[tuple[str, Callable[[Context], Response]]]: """ diff --git a/pyeudiw/satosa/default/response_handler.py b/pyeudiw/satosa/default/response_handler.py index 97d43c5f..3ef9155c 100644 --- a/pyeudiw/satosa/default/response_handler.py +++ b/pyeudiw/satosa/default/response_handler.py @@ -11,7 +11,7 @@ from pyeudiw.openid4vp.authorization_response import AuthorizeResponseDirectPost, AuthorizeResponsePayload from pyeudiw.openid4vp.exceptions import InvalidVPToken, KIDNotFound -from pyeudiw.openid4vp.nuovo_proposta import IdeaVpVerifier +from pyeudiw.openid4vp.nuovo_proposta import VpTokenVerifier from pyeudiw.openid4vp.utils import infer_vp_iss, infer_vp_typ, infer_vp_header_claim from pyeudiw.openid4vp.vp import SUPPORTED_VC_TYPES, Vp from pyeudiw.openid4vp.vp_mock import MockVpVerifier @@ -183,8 +183,8 @@ def response_endpoint(self, context: Context, *args: tuple) -> Redirect | JsonRe encoded_vps: list[str] = [authz_payload.vp_token] if isinstance(authz_payload.vp_token, str) else authz_payload.vp_token for vp_token in encoded_vps: # -- START HERE -- - nuovo_verifier: IdeaVpVerifier = self._vp_verifier_factory(authz_payload.presentation_submission) - verified_claims = nuovo_verifier.get_verified_credential() + nuovo_verifier: VpTokenVerifier = self._vp_verifier_factory(authz_payload.presentation_submission) + verified_claims = nuovo_verifier.get_verifiable_credential() _ = verified_claims # consuma le credenziali # -- END HERE -- # simplified algorithm steps @@ -336,7 +336,7 @@ def _translate_response(self, response: dict, issuer: str, context: Context) -> internal_resp.subject_id = sub return internal_resp - def _vp_verifier_factory(self, presentation_submission: dict) -> IdeaVpVerifier: + def _vp_verifier_factory(self, presentation_submission: dict, token: str) -> VpTokenVerifier: # Idea: se vc+sd-jwt → IdeaNuovoVpVerifier # PROBLEM: come faccio a fare dependency injection (in questo caso, della challenge?) # ci devo pensare con calma, non sono sicurissimo di saperlo ora diff --git a/pyeudiw/trust/_log.py b/pyeudiw/trust/_log.py new file mode 100644 index 00000000..c5c1e1a3 --- /dev/null +++ b/pyeudiw/trust/_log.py @@ -0,0 +1,4 @@ +import logging + + +_package_logger = logging.getLogger(__name__) diff --git a/pyeudiw/trust/default/direct_trust.py b/pyeudiw/trust/default/direct_trust.py index 2e252862..23bd405f 100644 --- a/pyeudiw/trust/default/direct_trust.py +++ b/pyeudiw/trust/default/direct_trust.py @@ -1,29 +1,47 @@ -from jwcrypto.jwk import JWK - -from pyeudiw.trust.interface import IssuerTrustModel -from pyeudiw.vci.jwks_provider import VciJwksSource - - -class DirectTrustModel(IssuerTrustModel): - - def __init__(self, issuer_jwks_provider: VciJwksSource): - self.issuer_jwks_provider = issuer_jwks_provider - pass - - def get_verified_key(self, issuer: str, token_header: dict) -> JWK: - kid: str = token_header.get("kid", None) - if not kid: - raise ValueError("missing claim [kid] in token header") - jwks = self.issuer_jwks_provider.get_jwks(issuer) # TODO: handle exception - issuer_keys: list[dict] = jwks.get("keys", []) - found_jwks: list[dict] = [] - for key in issuer_keys: - obt_kid: str = key.get("kid", "") - if kid == obt_kid: - found_jwks.append(key) - if len(found_jwks) != 1: - raise ValueError(f"unable to uniquely identify a key with kid {kid} in appropriate section of issuer entity configuration") - try: - return JWK(**found_jwks[0]) - except Exception as e: - raise ValueError(f"unable to parse issuer jwk: {e}") +import time + +from pyeudiw.tools.utils import get_http_url +from pyeudiw.trust.interface import TrustEvaluator +from pyeudiw.vci.jwks_provider import CachedVciJwksSource, RemoteVciJwksSource, VciJwksSource +from pyeudiw.vci.utils import cacheable_get_http_url + + +DEFAULT_ISSUER_JWK_ENDPOINT = "/.well-known/jwt-vc-issuer" +DEFAULT_METADATA_ENDPOINT = "/.well-known/openid-credential-issuer" + + +class DirectTrust(TrustEvaluator): + pass + + +class DirectTrustSdJwtVc(DirectTrust): + + def __init__(self, httpc_params: dict, cache_ttl: int = 0, jwk_endpoint: str = DEFAULT_ISSUER_JWK_ENDPOINT, + metadata_endpoint: str = DEFAULT_METADATA_ENDPOINT): + self.httpc_params = httpc_params + self.cache_ttl = cache_ttl + self.jwk_endpoint = jwk_endpoint + self.metadata_endpoint = metadata_endpoint + self._vci_jwks_source: VciJwksSource = None + if self.cache_ttl == 0: + self._vci_jwks_source = RemoteVciJwksSource(httpc_params, jwk_endpoint) + else: + self._vci_jwks_source = CachedVciJwksSource(self.cache_ttl, httpc_params, jwk_endpoint) + + def get_public_keys(self, issuer: str) -> list[dict]: + """ + yields the public cryptographic material of the issuer + + :returns: a list of jwk(s) + """ + return self._vci_jwks_source.get_jwks(issuer) + + def get_metadata(self, issuer: str) -> dict: + if not issuer: + raise ValueError("invalid issuer: cannot be empty value") + issuer_normalized = [issuer if issuer[-1] != '/' else issuer[:-1]] + url = issuer_normalized + self.metadata_endpoint + if self.cache_ttl == 0: + return get_http_url(url, self.httpc_params)[0].json() + ttl_timestamp = round(time.time() / self.cache_ttl) + return cacheable_get_http_url(ttl_timestamp, url, self.httpc_params)[0].json() diff --git a/pyeudiw/trust/default/federation.py b/pyeudiw/trust/default/federation.py index 9c302d4b..6ec27911 100644 --- a/pyeudiw/trust/default/federation.py +++ b/pyeudiw/trust/default/federation.py @@ -1,14 +1,35 @@ +import logging +from typing import Any from jwcrypto.jwk import JWK +import json + +from satosa.context import Context +from satosa.response import Response + +from pyeudiw.jwk import JWK +from pyeudiw.jwt import JWSHelper +from pyeudiw.jwt.utils import decode_jwt_header +from pyeudiw.satosa.exceptions import (DiscoveryFailedError, + NotTrustedFederationError) +from pyeudiw.storage.exceptions import EntryNotFound +from pyeudiw.tools.base_logger import BaseLogger +from pyeudiw.tools.utils import exp_from_now, iat_now +from pyeudiw.trust import TrustEvaluationHelper +from pyeudiw.trust.trust_anchors import update_trust_anchors_ecs + + from pyeudiw.federation.policy import TrustChainPolicy from pyeudiw.jwt.utils import decode_jwt_payload -from pyeudiw.trust.interface import IssuerTrustModel +from pyeudiw.trust.interface import IssuerTrustEvaluator + +logger = logging.getLogger(__name__) -class FederationTrustModel(IssuerTrustModel): +class FederationTrustModel(IssuerTrustEvaluator): _ISSUER_METADATA_TYPE = "openid_credential_issuer" - def __init__(self): + def __init__(self, **kwargs): # TODO; qui c'è dentro tutta la ciccia: trust chain verification, root of trust, etc self.metadata_policy_resolver = TrustChainPolicy() pass @@ -63,3 +84,202 @@ def get_verified_key(self, issuer: str, token_header: dict) -> JWK: return JWK(**found_jwks[0]) except Exception as e: raise ValueError(f"unable to parse issuer jwk: {e}") + + # --------------------------- + # TODO: sistema da qui in giù + # --------------------------- + + def __getattribute__(self, name: str) -> Any: + if hasattr(self, name): + return getattr(self, name) + logger.critical("se vedi questo messaggio: sei perduto") + return None + + def init_trust_resources(self) -> None: + """ + Initializes the trust resources. + """ + + # private keys by kid + self.federations_jwks_by_kids = { + i['kid']: i for i in self.config['federation']['federation_jwks'] + } + # dumps public jwks + self.federation_public_jwks = [ + JWK(i).public_key for i in self.config['federation']['federation_jwks'] + ] + # we close the connection in this constructor since it must be fork safe and + # get reinitialized later on, within each fork + self.update_trust_anchors() + + try: + self.get_backend_trust_chain() + except Exception as e: + self._log_critical( + "Backend Trust", + f"Cannot fetch the trust anchor configuration: {e}" + ) + + self.db_engine.close() + self._db_engine = None + + def entity_configuration_endpoint(self, context: Context) -> Response: + """ + Entity Configuration endpoint. + + :param context: The current context + :type context: Context + + :return: The entity configuration + :rtype: Response + """ + + if context.qs_params.get('format', '') == 'json': + return Response( + json.dumps(self.entity_configuration_as_dict), + status="200", + content="application/json" + ) + + return Response( + self.entity_configuration, + status="200", + content="application/entity-statement+jwt" + ) + + def update_trust_anchors(self): + """ + Updates the trust anchors of current instance. + """ + + tas = self.config['federation']['trust_anchors'] + self._log_info("Trust Anchors updates", f"Trying to update: {tas}") + + for ta in tas: + try: + update_trust_anchors_ecs( + db=self.db_engine, + trust_anchors=[ta], + httpc_params=self.config['network']['httpc_params'] + ) + except Exception as e: + self._log_warning("Trust Anchor updates", + f"{ta} update failed: {e}") + + self._log_info("Trust Anchor updates", f"{ta} updated") + + def get_backend_trust_chain(self) -> list[str]: + """ + Get the backend trust chain. In case something raises an Exception (e.g. faulty storage), logs a warning message + and returns an empty list. + + :return: The trust chain + :rtype: list + """ + try: + trust_evaluation_helper = TrustEvaluationHelper.build_trust_chain_for_entity_id( + storage=self.db_engine, + entity_id=self.client_id, + entity_configuration=self.entity_configuration, + httpc_params=self.config['network']['httpc_params'] + ) + self.db_engine.add_or_update_trust_attestation( + entity_id=self.client_id, + attestation=trust_evaluation_helper.trust_chain, + exp=trust_evaluation_helper.exp + ) + return trust_evaluation_helper.trust_chain + + except (DiscoveryFailedError, EntryNotFound, Exception) as e: + message = ( + f"Error while building trust chain for client with id: {self.client_id}. " + f"{e.__class__.__name__}: {e}" + ) + self._log_warning("Trust Chain", message) + + return [] + + def _validate_trust(self, context: Context, jws: str) -> TrustEvaluationHelper: + """ + Validates the trust of the given jws. + + :param context: the request context + :type context: satosa.context.Context + :param jws: the jws to validate + :type jws: str + + :raises: NotTrustedFederationError: raises an error if the trust evaluation fails. + + :return: the trust evaluation helper + :rtype: TrustEvaluationHelper + """ + + self._log_debug(context, "[TRUST EVALUATION] evaluating trust.") + + headers = decode_jwt_header(jws) + trust_eval = TrustEvaluationHelper( + self.db_engine, + httpc_params=self.config['network']['httpc_params'], + **headers + ) + + try: + trust_eval.evaluation_method() + except EntryNotFound: + message = ( + "[TRUST EVALUATION] not found for " + f"{trust_eval.entity_id}" + ) + self._log_error(context, message) + raise NotTrustedFederationError( + f"{trust_eval.entity_id} not found for Trust evaluation." + ) + except Exception as e: + message = ( + "[TRUST EVALUATION] failed for " + f"{trust_eval.entity_id}: {e}" + ) + self._log_error(context, message) + raise NotTrustedFederationError( + f"{trust_eval.entity_id} is not trusted." + ) + + return trust_eval + + # @property + # def default_federation_private_jwk(self) -> dict: + # """Returns the default federation private jwk.""" + # return tuple(self.federations_jwks_by_kids.values())[0] + + # @property + # def entity_configuration_as_dict(self) -> dict: + # """Returns the entity configuration as a dictionary.""" + # ec_payload = { + # "exp": exp_from_now(minutes=self.default_exp), + # "iat": iat_now(), + # "iss": self.client_id, + # "sub": self.client_id, + # "jwks": { + # "keys": self.federation_public_jwks + # }, + # "metadata": { + # self.config['federation']["metadata_type"]: self.config['metadata'], + # "federation_entity": self.config['federation']['federation_entity_metadata'] + # }, + # "authority_hints": self.config['federation']['authority_hints'] + # } + # return ec_payload + + # @property + # def entity_configuration(self) -> dict: + # """Returns the entity configuration as a JWT.""" + # data = self.entity_configuration_as_dict + # jwshelper = JWSHelper(self.default_federation_private_jwk) + # return jwshelper.sign( + # protected={ + # "alg": self.config['federation']["default_sig_alg"], + # "kid": self.default_federation_private_jwk["kid"], + # "typ": "entity-statement+jwt" + # }, + # plain_dict=data + # ) diff --git a/pyeudiw/trust/default/x509.py b/pyeudiw/trust/default/x509.py index f93b7801..329456ba 100644 --- a/pyeudiw/trust/default/x509.py +++ b/pyeudiw/trust/default/x509.py @@ -1,6 +1,6 @@ -from pyeudiw.trust.interface import IssuerTrustModel +from pyeudiw.trust.interface import IssuerTrustEvaluator -class X509TrustModel(IssuerTrustModel): - def __init__(self): +class X509TrustModel(IssuerTrustEvaluator): + def __init__(self, **kwargs): pass diff --git a/pyeudiw/trust/dynamic.py b/pyeudiw/trust/dynamic.py new file mode 100644 index 00000000..716b52b0 --- /dev/null +++ b/pyeudiw/trust/dynamic.py @@ -0,0 +1,40 @@ +import importlib +from typing import TypedDict + +from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc +from pyeudiw.trust.exceptions import TrustConfigurationError +from pyeudiw.trust.interface import TrustEvaluator +from pyeudiw.trust._log import _package_logger + + +_DynamicTrustConfiguration = TypedDict("_DynamicTrustConfiguration", {"module": str, "class": str, "config": dict}) + + +DEFAULT_HTTPC_PARAMS = { + "connection": { + "ssl": True + }, + "session": { + "timeout": 6 + } +} + + +def trust_evaluators_loader(trust_config: dict[str, _DynamicTrustConfiguration]) -> dict[str, TrustEvaluator]: + """ + Load a dynamically importable/configurable set of TrustEvaluators, + identified by the trust model they refer to. + """ + trust_instances: dict[str, TrustEvaluator] = {} + if not trust_config: + _package_logger.warning("no configured trust model, using direct trust model") + trust_instances["direct_trust"] = DirectTrustSdJwtVc(DEFAULT_HTTPC_PARAMS) + for trust_model_name, module_config in trust_config.items(): + try: + module = importlib.import_module(module_config["module"]) + class_type: type[TrustEvaluator] = getattr(module, module_config["class"]) + class_config: dict = module_config["config"] + except Exception as e: + raise TrustConfigurationError(f"invalid configuration for {trust_model_name}: {e}", e) + trust_instances[trust_model_name] = class_type(**class_config) + return trust_instances diff --git a/pyeudiw/trust/exceptions.py b/pyeudiw/trust/exceptions.py index f73b61d7..e503834f 100644 --- a/pyeudiw/trust/exceptions.py +++ b/pyeudiw/trust/exceptions.py @@ -20,3 +20,7 @@ class InvalidTrustType(Exception): class InvalidAnchor(Exception): pass + + +class TrustConfigurationError(Exception): + pass diff --git a/pyeudiw/trust/interface.py b/pyeudiw/trust/interface.py index 1327f12e..0d125d2c 100644 --- a/pyeudiw/trust/interface.py +++ b/pyeudiw/trust/interface.py @@ -1,7 +1,97 @@ +import importlib + from jwcrypto.jwk import JWK +from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc +from pyeudiw.trust.exceptions import TrustConfigurationError +from pyeudiw.trust._log import _package_logger + + +class TrustEvaluator: + """ + TrustEvaluator is an interface that defined the expected behaviour of a + class that, as the very core, can: + (1) obtain the cryptographic material of an issuer, which might or might + not be trusted according to some trust model + (2) obtain the meta information about an issuer that is defined + according to some trust model + """ + + def get_public_keys(self, issuer: str) -> list[dict]: + """ + yields the public cryptographic material of the issuer + + :returns: a list of jwk(s); note that those key are _not_ necessarely + identified by a kid claim + """ + raise NotImplementedError + + def get_metadata(self, issuer: str) -> dict: + """ + yields a dictionary of metadata about an issuer, according to some + trust model. + """ + raise NotImplementedError + + def is_revoked(self, issuer: str) -> bool: + """ + yield if the trust toward the issuer was revoked according to some trust model; + this asusmed that the isser exists, is valid, but is not trusted. + """ + raise NotImplementedError + + def get_policies(self, issuer: str) -> dict: + raise NotImplementedError("reserved for future uses") + + +DEFAULT_HTTPC_PARAMS = { + "connection": { + "ssl": True + }, + "session": { + "timeout": 6 + } +} + + +class IssuerTrustEvaluator: + + def __init__(self, trust_config: dict): + self.trust_configs: dict = trust_config + self.trust_methods: dict[str, object] = {} + if not self.trust_configs: + _package_logger.warning("no configured trust model, using direct trust model") + self.trust_methods["direct_trust"] = DirectTrustSdJwtVc(DEFAULT_HTTPC_PARAMS) + return + for k, v in self.trust_configs.items(): + try: + module = importlib.import_module(v["module"]) + class_type = getattr(module, v["class"]) + class_config = v["config"] + except KeyError as e: + _package_logger.critical(f"invalid trust configuration for {k}: missing mandatory fields [module] and/or [class]") + raise TrustConfigurationError(f"invalid configuration for {k}: {e}", e) + except Exception as e: + raise TrustConfigurationError(f"invalid config: {e}", e) + _package_logger.debug(f"loading {class_type} with config {class_config}") + self.trust_methods[k] = class_type(**class_config) + + def get_public_keys(self, issuer: str) -> list[dict]: + """ + yields the public cryptographic material of the issuer + + :returns: a list of jwk(s) + """ + raise NotImplementedError + + def get_metadata(self, issuer: str) -> dict: + raise NotImplementedError + + def is_revoked(self, issuer: str) -> bool: + raise NotImplementedError -class IssuerTrustModel: + def get_policies(self, issuer: str) -> dict: + raise NotImplementedError("reserved for future uses") - def get_verified_key(issuer: str, token_header: dict) -> JWK: + def get_verified_key(self, issuer: str, token_header: dict) -> JWK: # ← TODO: consider removal raise NotImplementedError diff --git a/pyeudiw/vci/jwks_provider.py b/pyeudiw/vci/jwks_provider.py index ecae31f4..862b86df 100644 --- a/pyeudiw/vci/jwks_provider.py +++ b/pyeudiw/vci/jwks_provider.py @@ -1,8 +1,8 @@ -from functools import lru_cache -from urllib.parse import urlparse, ParseResult import time +from typing import Literal from pyeudiw.tools.utils import get_http_url +from pyeudiw.vci.utils import cacheable_get_http_url, final_issuer_endpoint DEFAULT_ENDPOINT = "/.well-known/jwt-vc-issuer" DEFAULT_TTL_CACHE = 60 * 60 # in seconds, hence 1 hour @@ -21,22 +21,42 @@ def __init__(self, httpc_params: dict, endpoint: str = DEFAULT_ENDPOINT): self.httpc_params = httpc_params self.endpoint = endpoint - def get_jwks(self, issuer: str) -> dict: - baseurl = urlparse(issuer) - well_known_path = self.endpoint + baseurl.path - well_known_uri: str = ParseResult(baseurl.scheme, baseurl.netloc, well_known_path, baseurl.params, baseurl.query, baseurl.fragment).geturl() - resp = get_http_url(well_known_uri, self.httpc_params) - resp_data: dict = resp[0].json() - if issuer != (obt_iss := resp_data.get("issuer", "")): - raise Exception(f"invalid issuing key metadata: expected issuer {issuer}, obtained {obt_iss}") - jwks = resp_data.get("jwks", None) - jwks_uri = resp_data.get("jwks_uri", None) + def _verify_response_issuer(self, exp_issuer: str, response_json: dict) -> None: + if exp_issuer != (obt_issuer := response_json.get("issuer", "")): + raise Exception(f"invalid issuing key metadata: expected issuer {exp_issuer}, obtained {obt_issuer}") + + def _get_jwk_metadata(self, uri: str) -> dict: + try: + resp = get_http_url(uri, self.httpc_params) + response: dict = resp[0].json() + return response + except Exception: + # TODO: handle exception + pass + + def _get_jwkset_from_jwkset_uri(self, jwkset_uri: str) -> list[dict]: + try: + resp = get_http_url(jwkset_uri, self.httpc_params) + jwks: dict[Literal["keys"], list[dict]] = resp[0].json() + return jwks.get("keys", []) + except Exception: + # TODO: handle exception + pass + + def _obtain_jwkset_from_response_json(self, response: dict) -> list[dict]: + jwks: dict[Literal["keys"], list[dict]] = response.get("jwks", None) + jwks_uri = response.get("jwks_uri", None) if (not jwks) and (not jwks_uri): raise Exception("invalid issuing key metadata: missing both claims [jwks] and [jwks_uri]") - if not jwks: - return jwks - resp = get_http_url(jwks_uri, self.httpc_params) - return resp[0].json() + if jwks: + return jwks.get("keys", []) + return self._get_jwkset_from_jwkset_uri(jwks_uri) + + def get_jwks(self, issuer: str) -> list[dict]: + well_known_url = final_issuer_endpoint(issuer, self.endpoint) + resp_data = self._get_jwk_metadata(well_known_url) + self._verify_response_issuer(issuer, resp_data) + return self._obtain_jwkset_from_response_json(resp_data) class CachedVciJwksSource(RemoteVciJwksSource): @@ -45,15 +65,41 @@ def __init__(self, ttl_cache: int = DEFAULT_TTL_CACHE, *args, **kwargs): super().__init__(*args, **kwargs) self.ttl_cache = ttl_cache - def get_jwks(self, issuer: str) -> dict: - return self._get_jwks(issuer, self._get_ttl()) + def _get_jwk_metadata(self, uri: str) -> dict: + ttl_timestamp = round(time.time() / self.ttl_cache) + try: + resp = cacheable_get_http_url(ttl_timestamp, uri, self.httpc_params) + response: dict = resp[0].json() + return response + except Exception: + # TODO: handle exception + pass + + def _get_jwkset_from_jwkset_uri(self, jwkset_uri: str) -> list[dict]: + ttl_timestamp = round(time.time() / self.ttl_cache) + try: + resp = cacheable_get_http_url(ttl_timestamp. jwkset_uri, self.httpc_params) + jwks: dict[Literal["keys"], list[dict]] = resp[0].json() + return jwks.get("keys", []) + except Exception: + # TODO: handle exception + pass - def _get_ttl(self) -> int: - return round(time.time() / self.ttl_cache) + def _obtain_jwkset_from_response_json(self, response: dict) -> list[dict]: + jwks: dict[Literal["keys"], list[dict]] = response.get("jwks", None) + jwks_uri = response.get("jwks_uri", None) + if (not jwks) and (not jwks_uri): + raise Exception("invalid issuing key metadata: missing both claims [jwks] and [jwks_uri]") + if jwks: + return jwks.get("keys", []) + try: + ttl_timestamp = round(time.time() / self.ttl_cache) + resp = cacheable_get_http_url(ttl_timestamp, jwks_uri, self.httpc_params) + jwks = resp[0].json() + return jwks.get("keys", []) + except Exception: + # TODO: handle exception + pass - @lru_cache - def _get_jwks(self, issuer: str, ttl_timestamp: int): - # TODO: check che questa cache funzioni veramente ☺: - # la cache potrebbe fallire a cuase dell'argomento self; in caso definitsci una cached_get_http_url(urls, http_params, time_to_live_timestamp) - del ttl_timestamp # this is used to a have an in-memory time based cache using the tools in the Python standard library only. - return RemoteVciJwksSource.get_jwks(self, issuer) + def get_jwks(self, issuer: str) -> list[dict]: + return super().get_jwks(issuer) diff --git a/pyeudiw/vci/utils.py b/pyeudiw/vci/utils.py new file mode 100644 index 00000000..4932d31c --- /dev/null +++ b/pyeudiw/vci/utils.py @@ -0,0 +1,28 @@ +from functools import lru_cache +from urllib.parse import ParseResult, urlparse + +import requests + +from pyeudiw.tools.utils import get_http_url + + +def final_issuer_endpoint(issuer: str, wk_endpoint: str) -> str: + """Prepend the wk_endpoint part tot he path of the issuer. + For example, if the issuer is 'https://example.com/tenant/1234' and the + well known endpoint is '/.well-known/jwt-vc-issuer', then the final + endpoint will be + 'https://example.com/.well-known/jwt-vc-issuer/tenant/1234' + """ + baseurl = urlparse(issuer) + well_known_path = wk_endpoint + baseurl.path + well_known_url: str = ParseResult(baseurl.scheme, baseurl.netloc, well_known_path, baseurl.params, baseurl.query, baseurl.fragment).geturl() + return well_known_url + + +@lru_cache +def cacheable_get_http_url(ttl_cache: int, urls: list[str] | str, httpc_params: dict, http_async: bool = True) -> list[requests.Response]: + """ + wraps method 'get_http_url' around a ttl cache + """ + del ttl_cache + return get_http_url(urls, httpc_params, http_async) From 650e844a596394ceaa2f2458714983542a346bd7 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Fri, 27 Sep 2024 08:43:22 +0200 Subject: [PATCH 04/13] wip: backend configuration example --- example/satosa/pyeudiw_backend.yaml | 92 +++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/example/satosa/pyeudiw_backend.yaml b/example/satosa/pyeudiw_backend.yaml index 045b5760..21036cd7 100644 --- a/example/satosa/pyeudiw_backend.yaml +++ b/example/satosa/pyeudiw_backend.yaml @@ -80,29 +80,75 @@ config: session: timeout: 6 - federation: - metadata_type: "wallet_relying_party" - authority_hints: - - http://127.0.0.1:8000 - trust_anchors: - - http://127.0.0.1:8000 - default_sig_alg: "RS256" - federation_entity_metadata: - organization_name: Developers Italia SATOSA OpenID4VP backend - homepage_uri: https://developers.italia.it - policy_uri: https://developers.italia.it - tos_uri: https://developers.italia.it - logo_uri: https://developers.italia.it/assets/icons/logo-it.svg - - # private jwk - federation_jwks: - - kty: RSA - d: QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q - e: AQAB - kid: 9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w - n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw - p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0 - q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM + trust: + federation: + module: pyeudiw.trust.default.federation + class: FederationTrustModel + config: + metadata_type: "openid_credential_verifier" + authority_hints: + - http://127.0.0.1:8000 + trust_anchors: + - public_keys: [ ... ] + - http://127.0.0.1:8000 + default_sig_alg: "RS256" + federation_entity_metadata: + organization_name: Developers Italia SATOSA OpenID4VP backend + homepage_uri: https://developers.italia.it + policy_uri: https://developers.italia.it + tos_uri: https://developers.italia.it + logo_uri: https://developers.italia.it/assets/icons/logo-it.svg + federation_jwks: + - kty: RSA + d: QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q + e: AQAB + kid: 9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w + n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw + p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0 + q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM + + x509: + module: pyeudiw.trust.default.x509 + class: X509TrustModel + config: + trust_anchor_certificates: + - "todo" + trust_anchors_cn: # we might mix CN and SAN together + - http://127.0.0.1:8000 + direct_trust: + module: pyeudiw.trust.default.direct_trust + class: DirectTrustSdJwtVc + config: + endpoint: /.well-known/jwt-vc-issuer + httpc_params: + connection: + ssl: true + session: + timeout: 6 + # FORMER IMPLEMENTATION OF TRUST CONFIGURATION + # federation: + # metadata_type: "wallet_relying_party" + # authority_hints: + # - http://127.0.0.1:8000 + # trust_anchors: + # - http://127.0.0.1:8000 + # default_sig_alg: "RS256" + # federation_entity_metadata: + # organization_name: Developers Italia SATOSA OpenID4VP backend + # homepage_uri: https://developers.italia.it + # policy_uri: https://developers.italia.it + # tos_uri: https://developers.italia.it + # logo_uri: https://developers.italia.it/assets/icons/logo-it.svg + + # # private jwk + # federation_jwks: + # - kty: RSA + # d: QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q + # e: AQAB + # kid: 9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w + # n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw + # p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0 + # q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM # trust_marks: # todo # - ... From 7dad0a7d175325f5fb30671e23b394712cf425fb Mon Sep 17 00:00:00 2001 From: Zicchio Date: Fri, 27 Sep 2024 16:38:30 +0200 Subject: [PATCH 05/13] wip --- pyeudiw/jwk/__init__.py | 1 + pyeudiw/openid4vp/nuovo_proposta.py | 70 ++++- pyeudiw/satosa/default/openid4vp_backend.py | 9 +- pyeudiw/satosa/schemas/config.py | 8 +- pyeudiw/tests/satosa/test_backend.py | 13 +- pyeudiw/tests/satosa/test_backend_trust.py | 28 ++ pyeudiw/tests/settings.py | 304 +++++++++++++++++++- pyeudiw/tests/trust/test_dyanmic.py | 4 + pyeudiw/tools/utils.py | 21 ++ pyeudiw/trust/default/__init__.py | 20 ++ pyeudiw/trust/default/direct_trust.py | 27 +- pyeudiw/trust/dynamic.py | 99 +++++-- 12 files changed, 561 insertions(+), 43 deletions(-) create mode 100644 pyeudiw/tests/satosa/test_backend_trust.py create mode 100644 pyeudiw/tests/trust/test_dyanmic.py create mode 100644 pyeudiw/trust/default/__init__.py diff --git a/pyeudiw/jwk/__init__.py b/pyeudiw/jwk/__init__.py index db95c63f..b0624417 100644 --- a/pyeudiw/jwk/__init__.py +++ b/pyeudiw/jwk/__init__.py @@ -153,6 +153,7 @@ def jwk_form_dict(key: dict, hash_func: str = "SHA-256") -> RSAJWK | ECJWK: return ECJWK(key, hash_func, ec_crv) +# TODO: rename by find_jwk_by_kid def find_jwk(kid: str, jwks: list[dict], as_dict: bool = True) -> dict | JWK: """ Find the JWK with the indicated kid in the jwks list. diff --git a/pyeudiw/openid4vp/nuovo_proposta.py b/pyeudiw/openid4vp/nuovo_proposta.py index 74f35652..91e1a1a8 100644 --- a/pyeudiw/openid4vp/nuovo_proposta.py +++ b/pyeudiw/openid4vp/nuovo_proposta.py @@ -1,9 +1,18 @@ from dataclasses import dataclass +from typing import Optional +from jwcrypto.common import base64url_decode, json_decode +from sd_jwt.common import SDJWTCommon from sd_jwt.verifier import SDJWTVerifier +from pyeudiw.jwk import JWK +from pyeudiw.jwt import JWSHelper +from pyeudiw.jwt.schemas.jwt import UnverfiedJwt +from pyeudiw.jwt.utils import unsafe_parse_jws from pyeudiw.openid4vp.vp_sd_jwt_kb import VerifierChallenge -from pyeudiw.trust.interface import IssuerTrustEvaluator +from pyeudiw.sd_jwt.schema import is_sd_jwt_kb_format +from pyeudiw.tools.utils import iat_now +from pyeudiw.trust.interface import IssuerTrustEvaluator, TrustEvaluator class VpTokenParser: @@ -33,13 +42,70 @@ def is_revoked(self) -> bool: def is_active(self) -> bool: return (not self.is_expired()) and (not self.is_revoked()) - def verify_signature(self) -> None: + def verify_signature(self, public_key: JWK) -> None: """ :raises [InvalidSignatureException]: """ return +class VpVcSdJwtParserVerifier(VpTokenParser, VpTokenVerifier): + def __init__(self, sdjwtkb: str, verifier_id: Optional[str] = None, verifier_nonce: Optional[str] = None): + self.sdjwtkb = sdjwtkb + if not is_sd_jwt_kb_format(sdjwtkb): + raise ValueError(f"input [sdjwtkb]={sdjwtkb} is not an sd-jwt with key binding: maybe it is a regular jwt or key binding jwt is missing?") + self.verifier_id = verifier_id + self.verifier_nonce = verifier_nonce + # precomputed values + self._issuer_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") + self._encoded_disclosures: list[str] = [] + self._disclosures: list[dict] = [] + self._kb_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") + self._post_init_evaluate_precomputed_values() + + def _post_init_evaluate_precomputed_values(self): + iss_jwt, *disclosures, kb_jwt = self.sdjwtkb.split(SDJWTCommon.COMBINED_SERIALIZATION_FORMAT_SEPARATOR) + self._encoded_disclosures = disclosures + self._disclosures = [json_decode(base64url_decode(disc)) for disc in disclosures] + self._issuer_jwt = unsafe_parse_jws(iss_jwt) + self._kb_jwt = unsafe_parse_jws(kb_jwt) + + def get_issuer_name(self) -> str: + iss = self._issuer_jwt.payload.get("iss", None) + if not iss: + raise Exception("missing required information in token paylaod: [iss]") + + def get_credentials(self) -> dict: + # TODO: fa un sacco di copia incolla da SDJWTVerifier + raise NotImplementedError("TODO") + + def get_signing_key(self) -> dict | str: + # TODO: usa SOLO l'header del token -> la parte di match è fatta FUORI dalla classe + if (maybe_kid := self._issuer_jwt.header.get("kid", None)): + return maybe_kid + JWSHelper + if (maybe_trust_chain := self._issuer_jwt.header.get("trust_chain", None)): + return qualcosa che prende la chiave dalla trust chian + # ?????? + pass + + def is_revoked(self) -> bool: + return False + + def is_expired(self) -> bool: + exp = self._issuer_jwt.payload.get("exp", None) + if not exp: + return True + if exp < iat_now(): + return True + return False + + def verify_signature(self, public_key: JWK) -> None: + # TODO: usa questa PPK per fare la verifica delle public keys + # ??????: fa la verifica del kb jwt? dove? + + + @dataclass class IdeaNuovoVpVerifier(VpTokenVerifier): trust_model: IssuerTrustEvaluator diff --git a/pyeudiw/satosa/default/openid4vp_backend.py b/pyeudiw/satosa/default/openid4vp_backend.py index 9948532f..1d58fc03 100644 --- a/pyeudiw/satosa/default/openid4vp_backend.py +++ b/pyeudiw/satosa/default/openid4vp_backend.py @@ -1,4 +1,3 @@ -import json import uuid from typing import Callable from urllib.parse import quote_plus, urlencode @@ -18,6 +17,7 @@ from pyeudiw.storage.exceptions import StorageWriteError from pyeudiw.tools.mobile import is_smartphone from pyeudiw.tools.utils import iat_now +from pyeudiw.trust.dynamic import CombinedTrustEvaluator, dynamic_trust_evaluators_loader from pyeudiw.trust.interface import IssuerTrustEvaluator from ..interfaces.openid4vp_backend import OpenID4VPBackendInterface @@ -95,11 +95,8 @@ def __init__( self._log_warning("OpenID4VPBackend", debug_message) self.response_code_helper = ResponseCodeSource(self.config["response_code"]["sym_key"]) - self.issuer_trust_model: IssuerTrustEvaluator = self._trust_model_factory() - self._log_debug( - "OpenID4VP init", - f"loaded configuration: {json.dumps(config)}" - ) + trust_configuration = self.config.get("trust", {}) + self.trust_evaluator = CombinedTrustEvaluator(dynamic_trust_evaluators_loader(trust_configuration)) def _trust_model_factory(self) -> IssuerTrustEvaluator: """Questa funzione eroga uno (o più?) Issuer Trust Model basandosi sulle configurazioni dell'applicativo. diff --git a/pyeudiw/satosa/schemas/config.py b/pyeudiw/satosa/schemas/config.py index 75e2cae7..be8f1b2d 100644 --- a/pyeudiw/satosa/schemas/config.py +++ b/pyeudiw/satosa/schemas/config.py @@ -1,15 +1,15 @@ from pydantic import BaseModel +from pyeudiw.federation.schemas.wallet_relying_party import WalletRelyingParty +from pyeudiw.jwt.schemas.jwt import JWTConfig from pyeudiw.jwk.schemas.public import JwkSchema from pyeudiw.satosa.schemas.endpoint import EndpointsConfig from pyeudiw.satosa.schemas.qrcode import QRCode from pyeudiw.satosa.schemas.response import ResponseConfig from pyeudiw.satosa.schemas.autorization import AuthorizationConfig from pyeudiw.satosa.schemas.user_attributes import UserAttributesConfig -from pyeudiw.federation.schemas.federation_configuration import FederationConfig -from pyeudiw.federation.schemas.wallet_relying_party import WalletRelyingParty from pyeudiw.satosa.schemas.ui import UiConfig -from pyeudiw.jwt.schemas.jwt import JWTConfig from pyeudiw.storage.schemas.storage import Storage +from pyeudiw.trust.dynamic import TrustModuleConfiguration_T class PyeudiwBackendConfig(BaseModel): @@ -21,7 +21,7 @@ class PyeudiwBackendConfig(BaseModel): authorization: AuthorizationConfig user_attributes: UserAttributesConfig network: dict - federation: FederationConfig + trust: dict[str, TrustModuleConfiguration_T] metadata_jwks: list[JwkSchema] storage: Storage metadata: WalletRelyingParty diff --git a/pyeudiw/tests/satosa/test_backend.py b/pyeudiw/tests/satosa/test_backend.py index 70fba217..95fa752a 100644 --- a/pyeudiw/tests/satosa/test_backend.py +++ b/pyeudiw/tests/satosa/test_backend.py @@ -38,8 +38,9 @@ from pyeudiw.tests.settings import ( BASE_URL, CONFIG, + CREDENTIAL_ISSUER_ENTITY_ID, INTERNAL_ATTRIBUTES, - ISSUER_CONF, + CREDENTIAL_ISSUER_CONF, PRIVATE_JWK, WALLET_INSTANCE_ATTESTATION ) @@ -168,8 +169,8 @@ def test_vp_validation_in_response_endpoint(self, context): issuer_jwk = JWK(leaf_cred_jwk_prot.serialize(private=True)) holder_jwk = JWK(leaf_wallet_jwk.serialize(private=True)) - settings = ISSUER_CONF - settings['issuer'] = "https://issuer.example.com" + settings = CREDENTIAL_ISSUER_CONF + settings['issuer'] = CREDENTIAL_ISSUER_ENTITY_ID settings['default_exp'] = CONFIG['jwt']['default_exp'] sd_specification = load_specification_from_yaml_string( @@ -260,14 +261,14 @@ def test_vp_validation_in_response_endpoint(self, context): assert msg["error"] == "invalid_request" assert msg["error_description"] == "DirectPostResponse content parse and validation error. Single VPs are faulty." - def test_redirect_endpoint(self, context): + def test_response_endpoint(self, context): self.backend.register_endpoints() issuer_jwk = JWK(leaf_cred_jwk_prot.serialize(private=True)) holder_jwk = JWK(leaf_wallet_jwk.serialize(private=True)) - settings = ISSUER_CONF - settings['issuer'] = "https://issuer.example.com" + settings = CREDENTIAL_ISSUER_CONF + settings['issuer'] = CREDENTIAL_ISSUER_ENTITY_ID settings['default_exp'] = CONFIG['jwt']['default_exp'] sd_specification = load_specification_from_yaml_string( diff --git a/pyeudiw/tests/satosa/test_backend_trust.py b/pyeudiw/tests/satosa/test_backend_trust.py new file mode 100644 index 00000000..7d240e79 --- /dev/null +++ b/pyeudiw/tests/satosa/test_backend_trust.py @@ -0,0 +1,28 @@ +import pytest + +from unittest.mock import Mock, patch + +from pyeudiw.satosa.backend import OpenID4VPBackend + +from pyeudiw.tests.settings import ( + BASE_URL, + CONFIG_DIRECT_TRUST, + INTERNAL_ATTRIBUTES, +) + + +class TestOpenID4VPBackend: + + @pytest.fixture(autouse=True) + def setup_direct_trust(self): + self.backend = OpenID4VPBackend( + Mock(), + INTERNAL_ATTRIBUTES, + CONFIG_DIRECT_TRUST, + BASE_URL, + "name" + ) + + def test_response_endpoint(self): + # TODO + pass diff --git a/pyeudiw/tests/settings.py b/pyeudiw/tests/settings.py index 9274bee8..50bc0553 100644 --- a/pyeudiw/tests/settings.py +++ b/pyeudiw/tests/settings.py @@ -341,7 +341,309 @@ } } -ISSUER_CONF = { +CREDENTIAL_ISSUER_ENTITY_ID = "https://issuer.example.com" + +MODULE_DIRECT_TRUST_CONFIG = { + "module": "pyeudiw.trust.default.direct_trust", + "class": "DirectTrustSdJwtVc", + "config": { + "endpoint": "/.well-known/jwt-vc-issuer", + "httpc_params": { + "connection": { + "ssl": True + }, + "session": { + "timeout": 6 + } + } + } +} + +CONFIG_DIRECT_TRUST = { + "base_url": BASE_URL, + + "ui": { + "static_storage_url": BASE_URL, + "template_folder": f"{pathlib.Path().absolute().__str__()}/pyeudiw/tests/satosa/templates", + "qrcode_template": "qrcode.html", + "error_template": "error.html", + "error_url": "https://localhost:9999/error_page.html" + }, + "endpoints": { + "entity_configuration": "/.well-known/openid-federation", + "pre_request": "/pre-request", + "response": "/response-uri", + "request": "/request-uri", + "status": "/status-uri", + "get_response": "/get-response", + }, + "response_code": { + "sym_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "qrcode": { + "size": 100, + "color": "#2B4375", + "expiration_time": 120, + "logo_path": "pyeudiw/tests/satosa/static/logo.png" + }, + "jwt": { + "default_sig_alg": "ES256", + "default_exp": 6 + }, + "authorization": { + "url_scheme": "haip", # haip:// + "scopes": ["pid-sd-jwt:unique_id+given_name+family_name"], + "default_acr_value": "https://www.spid.gov.it/SpidL2", + "expiration_time": 5, # minutes + }, + 'user_attributes': { + "unique_identifiers": ["tax_id_code", "unique_id"], + "subject_id_random_value": "CHANGEME!" + }, + 'network': { + "httpc_params": httpc_params + }, + "trust": { + "direct_trust": MODULE_DIRECT_TRUST_CONFIG + }, + "metadata_jwks": [ + { + "crv": "P-256", + "d": "KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc", + "kid": "dDwPWXz5sCtczj7CJbqgPGJ2qQ83gZ9Sfs-tJyULi6s", + "kty": "EC", + "x": "TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk", + "y": "ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7" + }, + { + "kty": "RSA", + "d": "QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCo" + "A-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_dj" + "h4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q", + "e": "AQAB", + "use": "enc", + "kid": "9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w", + "n": "utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1" + "uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3" + "zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw", + "p": "2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT" + "1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0", + "q": "2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jG" + "oWM5RHyl_HDDMI-UeLkzP7ImxGizrM" + } + ], + "storage": { + "mongo_db": { + "cache": { + "module": "pyeudiw.storage.mongo_cache", + "class": "MongoCache", + "init_params": { + "url": "mongodb://localhost:27017/?timeoutMS=2000", + "conf": { + "db_name": "eudiw" + }, + "connection_params": {} + } + }, + "storage": { + "module": "pyeudiw.storage.mongo_storage", + "class": "MongoStorage", + "init_params": { + "url": "mongodb://localhost:27017/?timeoutMS=2000", + "conf": { + "db_name": "test-eudiw", + "db_sessions_collection": "sessions", + "db_trust_attestations_collection": "trust_attestations", + "db_trust_anchors_collection": "trust_anchors" + }, + "connection_params": {} + } + } + } + }, + "metadata": { + "application_type": "web", + "authorization_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "authorization_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + "authorization_signed_response_alg": [ + "RS256", + "ES256" + ], + "client_id": f"{BASE_URL}/OpenID4VP", + "client_name": "Name of an example organization", + "contacts": [ + "ops@verifier.example.org" + ], + "default_acr_values": [ + "https://www.spid.gov.it/SpidL2", + "https://www.spid.gov.it/SpidL3" + ], + "default_max_age": 1111, + "id_token_encrypted_response_alg": [ + "RSA-OAEP", + "RSA-OAEP-256" + ], + "id_token_encrypted_response_enc": [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" + ], + "id_token_signed_response_alg": [ + "RS256", + "ES256" + ], + "presentation_definitions": [ + { + "id": "pid-sd-jwt:unique_id+given_name+family_name", + "input_descriptors": [ + { + "format": { + "constraints": { + "fields": [ + { + "filter": { + "const": "PersonIdentificationData", + "type": "string" + }, + "path": [ + "$.sd-jwt.type" + ] + }, + { + "filter": { + "type": "object" + }, + "path": [ + "$.sd-jwt.cnf" + ] + }, + { + "intent_to_retain": "true", + "path": [ + "$.sd-jwt.family_name" + ] + }, + { + "intent_to_retain": "true", + "path": [ + "$.sd-jwt.given_name" + ] + }, + { + "intent_to_retain": "true", + "path": [ + "$.sd-jwt.unique_id" + ] + } + ], + "limit_disclosure": "required" + }, + "jwt": { + "alg": [ + "EdDSA", + "ES256" + ] + } + }, + "id": "sd-jwt" + } + ] + }, + { + "id": "mDL-sample-req", + "input_descriptors": [ + { + "format": { + "constraints": { + "fields": [ + { + "filter": { + "const": "org.iso.18013.5.1.mDL", + "type": "string" + }, + "path": [ + "$.mdoc.doctype" + ] + }, + { + "filter": { + "const": "org.iso.18013.5.1", + "type": "string" + }, + "path": [ + "$.mdoc.namespace" + ] + }, + { + "intent_to_retain": "false", + "path": [ + "$.mdoc.family_name" + ] + }, + { + "intent_to_retain": "false", + "path": [ + "$.mdoc.portrait" + ] + }, + { + "intent_to_retain": "false", + "path": [ + "$.mdoc.driving_privileges" + ] + } + ], + "limit_disclosure": "required" + }, + "mso_mdoc": { + "alg": [ + "EdDSA", + "ES256" + ] + } + }, + "id": "mDL" + } + ] + } + ], + "response_uris_supported": [ + f"{BASE_URL}/OpenID4VP/response-uri" + ], + "request_uris": [ + f"{BASE_URL}/OpenID4VP/request-uri" + ], + "require_auth_time": True, + "subject_type": "pairwise", + "vp_formats": { + "vc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256", + "ES384" + ], + "kb-jwt_alg_values": [ + "ES256", + "ES384" + ] + } + } + } +} + +CREDENTIAL_ISSUER_CONF = { "sd_specification": """ user_claims: !sd unique_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" diff --git a/pyeudiw/tests/trust/test_dyanmic.py b/pyeudiw/tests/trust/test_dyanmic.py new file mode 100644 index 00000000..1a8d6496 --- /dev/null +++ b/pyeudiw/tests/trust/test_dyanmic.py @@ -0,0 +1,4 @@ +def test_trust_evaluators_loader(): + config = { + } + # TODO \ No newline at end of file diff --git a/pyeudiw/tools/utils.py b/pyeudiw/tools/utils.py index f015f047..bc8ff1d7 100644 --- a/pyeudiw/tools/utils.py +++ b/pyeudiw/tools/utils.py @@ -177,3 +177,24 @@ def dynamic_class_loader(module_name: str, class_name: str, init_params: dict = storage_instance = get_dynamic_class( module_name, class_name)(**init_params) return storage_instance + + +def satisfy_interface(o: object, interface: type) -> bool: + """ + Returns true if and only if an object satisfy an interface. + + :param o: an object (instance of a class) + :type o: object + :param interface: an interface type + :type interface: type + + :returns: True if the object satisfy the interface, otherwise False + """ + for cls_attr in dir(interface): + if cls_attr.startswith('_'): + continue + if not hasattr(o, cls_attr): + return False + if callable(getattr(interface, cls_attr)) and not callable(getattr(o, cls_attr)): + return False + return True diff --git a/pyeudiw/trust/default/__init__.py b/pyeudiw/trust/default/__init__.py new file mode 100644 index 00000000..1ace8999 --- /dev/null +++ b/pyeudiw/trust/default/__init__.py @@ -0,0 +1,20 @@ +import os + +from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc +from pyeudiw.trust.interface import TrustEvaluator + + +DEFAULT_DIRECT_TRUST_PARAMS = { + "httpc_params": { + "connection": { + "ssl": os.getenv("PYEUDIW_HTTPC_SSL", True) + }, + "session": { + "timeout": os.getenv("PYEUDIW_HTTPC_TIMEOUT", 6) + } + } +} + + +def default_trust_evaluator() -> TrustEvaluator: + return DirectTrustSdJwtVc(**DEFAULT_DIRECT_TRUST_PARAMS) diff --git a/pyeudiw/trust/default/direct_trust.py b/pyeudiw/trust/default/direct_trust.py index 23bd405f..b4fb271c 100644 --- a/pyeudiw/trust/default/direct_trust.py +++ b/pyeudiw/trust/default/direct_trust.py @@ -15,7 +15,13 @@ class DirectTrust(TrustEvaluator): class DirectTrustSdJwtVc(DirectTrust): - + """ + DirectTrust trust models assumes that an issuer is always trusted, in the sense + that no trust verification actually happens. The issuer is assumed to be an URI + and its keys and metadata information are publicly exposed on the web. + Such keys/metadata can always be fetched remotely and long as the issuer is + available. + """ def __init__(self, httpc_params: dict, cache_ttl: int = 0, jwk_endpoint: str = DEFAULT_ISSUER_JWK_ENDPOINT, metadata_endpoint: str = DEFAULT_METADATA_ENDPOINT): self.httpc_params = httpc_params @@ -30,13 +36,22 @@ def __init__(self, httpc_params: dict, cache_ttl: int = 0, jwk_endpoint: str = D def get_public_keys(self, issuer: str) -> list[dict]: """ - yields the public cryptographic material of the issuer + Fetches the public key of the issuer by querying a given endpoint. + Previous responses might or might not be cached based on the cache_ttl + parameter. :returns: a list of jwk(s) """ return self._vci_jwks_source.get_jwks(issuer) def get_metadata(self, issuer: str) -> dict: + """ + Fetches the public metadata of an issuer by interrogating a given + endpoint. The endpoint must yield information in a format that + can be transalted to a meaning dictionary (such as json) + + :returns: a dictionary of metadata information + """ if not issuer: raise ValueError("invalid issuer: cannot be empty value") issuer_normalized = [issuer if issuer[-1] != '/' else issuer[:-1]] @@ -45,3 +60,11 @@ def get_metadata(self, issuer: str) -> dict: return get_http_url(url, self.httpc_params)[0].json() ttl_timestamp = round(time.time() / self.cache_ttl) return cacheable_get_http_url(ttl_timestamp, url, self.httpc_params)[0].json() + + def __str__(self) -> str: + return f"DirectTrustSdJwtVc(" \ + f"httpc_params={self.httpc_params}, " \ + f"cache_ttl={self.cache_ttl}, " \ + f"jwk_endpoint={self.jwk_endpoint}, " \ + f"metadata_endpoint={self.metadata_endpoint}" \ + ")" diff --git a/pyeudiw/trust/dynamic.py b/pyeudiw/trust/dynamic.py index 716b52b0..b68e3f8f 100644 --- a/pyeudiw/trust/dynamic.py +++ b/pyeudiw/trust/dynamic.py @@ -1,40 +1,95 @@ -import importlib from typing import TypedDict -from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc +from pyeudiw.tools.utils import get_dynamic_class, satisfy_interface +from pyeudiw.trust.default import default_trust_evaluator from pyeudiw.trust.exceptions import TrustConfigurationError from pyeudiw.trust.interface import TrustEvaluator from pyeudiw.trust._log import _package_logger -_DynamicTrustConfiguration = TypedDict("_DynamicTrustConfiguration", {"module": str, "class": str, "config": dict}) +TrustModuleConfiguration_T = TypedDict("_DynamicTrustConfiguration", {"module": str, "class": str, "config": dict}) -DEFAULT_HTTPC_PARAMS = { - "connection": { - "ssl": True - }, - "session": { - "timeout": 6 - } -} - - -def trust_evaluators_loader(trust_config: dict[str, _DynamicTrustConfiguration]) -> dict[str, TrustEvaluator]: - """ - Load a dynamically importable/configurable set of TrustEvaluators, +def dynamic_trust_evaluators_loader(trust_config: dict[str, TrustModuleConfiguration_T]) -> dict[str, TrustEvaluator]: + """Load a dynamically importable/configurable set of TrustEvaluators, identified by the trust model they refer to. + If not configurations a re given, a default is returned instead + implementation of TrustEvaluator is returned instead. + + :return: a dictionary where the keys are common name identifiers + for the trust mechanism ,a nd the keys are acqual class instances that satisfy + the TrustEvaluator interface + :rtype: dict[str, TrustEvaluator] """ trust_instances: dict[str, TrustEvaluator] = {} if not trust_config: _package_logger.warning("no configured trust model, using direct trust model") - trust_instances["direct_trust"] = DirectTrustSdJwtVc(DEFAULT_HTTPC_PARAMS) - for trust_model_name, module_config in trust_config.items(): + trust_instances["direct_trust"] = default_trust_evaluator() + return trust_instances + + for trust_model_name, trust_module_config in trust_config.items(): try: - module = importlib.import_module(module_config["module"]) - class_type: type[TrustEvaluator] = getattr(module, module_config["class"]) - class_config: dict = module_config["config"] + uninstantiated_class: type[TrustEvaluator] = get_dynamic_class(trust_module_config["module"], trust_module_config["class"]) + class_config: dict = trust_module_config["config"] + trust_evaluator_instance = uninstantiated_class(**class_config) except Exception as e: raise TrustConfigurationError(f"invalid configuration for {trust_model_name}: {e}", e) - trust_instances[trust_model_name] = class_type(**class_config) + if not satisfy_interface(trust_evaluator_instance, TrustEvaluator): + raise TrustConfigurationError(f"class {uninstantiated_class} does not satisfy the interface TrustEvaluator") + trust_instances[trust_model_name] = trust_evaluator_instance return trust_instances + + +class CombinedTrustEvaluator(TrustEvaluator): + """CombinedTrustEvaluator is a wrapper around multiple implementations of + TrustEvaluator. It's primary purpose is to handle how multiple configured + trust sources are queried when some metadata or key material is requested. + """ + + def __init__(self, trust_evaluators: dict[str, TrustEvaluator]): + self.trust_evaluators: dict[str, TrustEvaluator] = trust_evaluators + + # def __iter__(self): + # for eval_identifier, eval_instance in self.trust_evaluators.items(): + # yield (eval_identifier, eval_instance) + + def _get_trust_identifier_names(self) -> str: + return '['+','.join(self.trust_evaluators.keys())+']' + + def get_public_keys(self, issuer: str) -> list[dict]: + """ + yields the public cryptographic material of the issuer + + :returns: a list of jwk(s); note that those key are _not_ necessarely + identified by a kid claim + """ + pks: list[dict] = [] + for eval_identifier, eval_instance in self.trust_evaluators.items(): + pks = eval_instance.get_public_keys(issuer) + if pks: + return pks + if not pks: + raise Exception(f"no trust evaluator can provide cyptographic matrerial for {issuer}: searched among: {self._get_trust_identifier_names()}") + + def get_metadata(self, issuer: str) -> dict: + """ + yields a dictionary of metadata about an issuer, according to some + trust model. + """ + md: dict = {} + for eval_identifier, eval_instance in self.trust_evaluators.items(): + md = eval_instance.get_metadata(issuer) + if md: + return md + if not md: + raise Exception(f"no trust evaluator can provide metadata for {issuer}: searched among: {self._get_trust_identifier_names()}") + + def is_revoked(self, issuer: str) -> bool: + """ + yield if the trust toward the issuer was revoked according to some trust model; + this asusmed that the isser exists, is valid, but is not trusted. + """ + raise NotImplementedError("implementation details yet to be deifined for combined use") + + def get_policies(self, issuer: str) -> dict: + raise NotImplementedError("reserved for future uses") From a374ad8a778a2ad3340fbe362a35cf5efc7b2121 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Tue, 1 Oct 2024 15:59:28 +0200 Subject: [PATCH 06/13] wip: stub of response with trust model and vp parser --- pyeudiw/federation/trust_chain/__init__.py | 0 pyeudiw/federation/trust_chain/builder.py | 1 + pyeudiw/federation/trust_chain/parse.py | 5 + pyeudiw/federation/trust_chain/validator.py | 1 + pyeudiw/jwt/parse.py | 40 ++++ pyeudiw/jwt/utils.py | 33 ++- pyeudiw/jwt/verification.py | 30 +++ pyeudiw/openid4vp/interface.py | 46 ++++ pyeudiw/openid4vp/nuovo_proposta.py | 127 ----------- pyeudiw/openid4vp/vp_sd_jwt_vc.py | 39 ++++ pyeudiw/openid4vp/vp_void.py | 41 ++++ pyeudiw/satosa/default/response_handler.py | 117 ++++------ pyeudiw/sd_jwt/__init__.py | 1 + pyeudiw/sd_jwt/exceptions.py | 8 + pyeudiw/sd_jwt/schema.py | 16 +- pyeudiw/sd_jwt/sd_jwt.py | 200 ++++++++++++++++++ pyeudiw/tests/trust/default/settings.py | 16 ++ .../tests/trust/default/test_direct_trust.py | 30 +++ pyeudiw/trust/interface.py | 52 ----- pyeudiw/x509/verify.py | 6 + 20 files changed, 537 insertions(+), 272 deletions(-) create mode 100644 pyeudiw/federation/trust_chain/__init__.py create mode 100644 pyeudiw/federation/trust_chain/builder.py create mode 100644 pyeudiw/federation/trust_chain/parse.py create mode 100644 pyeudiw/federation/trust_chain/validator.py create mode 100644 pyeudiw/jwt/parse.py create mode 100644 pyeudiw/openid4vp/interface.py delete mode 100644 pyeudiw/openid4vp/nuovo_proposta.py create mode 100644 pyeudiw/openid4vp/vp_sd_jwt_vc.py create mode 100644 pyeudiw/openid4vp/vp_void.py create mode 100644 pyeudiw/sd_jwt/sd_jwt.py create mode 100644 pyeudiw/tests/trust/default/settings.py create mode 100644 pyeudiw/tests/trust/default/test_direct_trust.py diff --git a/pyeudiw/federation/trust_chain/__init__.py b/pyeudiw/federation/trust_chain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyeudiw/federation/trust_chain/builder.py b/pyeudiw/federation/trust_chain/builder.py new file mode 100644 index 00000000..621a7348 --- /dev/null +++ b/pyeudiw/federation/trust_chain/builder.py @@ -0,0 +1 @@ +# TODO: move trust_chain_builder.py here diff --git a/pyeudiw/federation/trust_chain/parse.py b/pyeudiw/federation/trust_chain/parse.py new file mode 100644 index 00000000..7a188563 --- /dev/null +++ b/pyeudiw/federation/trust_chain/parse.py @@ -0,0 +1,5 @@ +from pyeudiw.jwk import JWK + + +def get_public_key_from_trust_chain(trust_chain: list[str]) -> JWK: + raise NotImplementedError("TODO") diff --git a/pyeudiw/federation/trust_chain/validator.py b/pyeudiw/federation/trust_chain/validator.py new file mode 100644 index 00000000..01c037d5 --- /dev/null +++ b/pyeudiw/federation/trust_chain/validator.py @@ -0,0 +1 @@ +# TODO: move trust_chain_validator.py here diff --git a/pyeudiw/jwt/parse.py b/pyeudiw/jwt/parse.py new file mode 100644 index 00000000..d211f758 --- /dev/null +++ b/pyeudiw/jwt/parse.py @@ -0,0 +1,40 @@ +from jwcrypto.common import base64url_decode, json_decode +from pyeudiw.federation.trust_chain.parse import get_public_key_from_trust_chain +from pyeudiw.jwk import JWK +from pyeudiw.jwt.schemas.jwt import UnverfiedJwt +from pyeudiw.jwt.utils import is_jwt_format +from pyeudiw.x509.verify import get_public_key_from_x509_chain + +KeyIdentifier_T = str + + +def _unsafe_decode_part(part: str) -> dict: + return json_decode(base64url_decode(part)) + + +def unsafe_parse_jws(token: str) -> UnverfiedJwt: + """Parse a token into it's component. + Correctness of this function is not guaranteed when the token is in a + derived format, such as sd-jwt and jwe. + """ + if not is_jwt_format(token): + raise ValueError(f"unable to parse {token}: not a jwt") + b64header, b64payload, signature, *_ = token.split(".") + head = {} + payload = {} + try: + head = _unsafe_decode_part(b64header) + payload = _unsafe_decode_part(b64payload) + except Exception as e: + raise ValueError(f"unable to decode JWS part: {e}") + return UnverfiedJwt(token, head, payload, signature=signature) + + +def extract_key_identifier(token_header: dict) -> JWK | KeyIdentifier_T: + if "trust_chain" in token_header.keys(): + return get_public_key_from_trust_chain(token_header["key"]) + if "x5c" in token_header.keys(): + return get_public_key_from_x509_chain(token_header["x5c"]) + if "kid" in token_header.keys(): + return KeyIdentifier_T(token_header["kid"]) + raise ValueError(f"unable to infer identifying key from token head: searched among keys {token_header.keys()}") diff --git a/pyeudiw/jwt/utils.py b/pyeudiw/jwt/utils.py index 1724c839..170b521d 100644 --- a/pyeudiw/jwt/utils.py +++ b/pyeudiw/jwt/utils.py @@ -3,11 +3,8 @@ import re from typing import Dict -from jwcrypto.common import base64url_decode, json_decode - from pyeudiw.jwk import find_jwk from pyeudiw.jwt.exceptions import JWTInvalidElementPosition -from pyeudiw.jwt.schemas.jwt import UnverfiedJwt # jwt regexp pattern is non terminating, hence it match jwt, sd-jwt and sd-jwt with kb JWT_REGEXP = r'^[_\w\-]+\.[_\w\-]+\.[_\w\-]+' @@ -143,19 +140,21 @@ def is_jws_format(jwt: str): return not is_jwe_format(jwt) -def _unsafe_decode_part(part: str) -> dict: - return json_decode(base64url_decode(part)) +def base64_urlencode(v: bytes) -> str: + """Urlsafe base64 encoding without padding symbols + :returns: the encooded data + :rtype: str + """ + return base64.urlsafe_b64encode(v).decode("ascii").strip("=") -def unsafe_parse_jws(jwt: str) -> UnverfiedJwt: - if not is_jwt_format(jwt): - raise ValueError(f"unable to parse {jwt}: not a jwt") - b64header, b64payload, signature, *_ = jwt.split(".") - head = {} - payload = {} - try: - head = _unsafe_decode_part(b64header) - payload = _unsafe_decode_part(b64payload) - except Exception as e: - raise ValueError(f"unable to decode JWS part: {e}") - return UnverfiedJwt(jwt, head, payload, signature=signature) + +def base64_urldecode(v: str) -> bytes: + """Urlsafe base64 decoding. This function will handle missing + padding symbols. + + :returns: the decoded data in bytes, format, convert to str use method '.decode("utf-8")' on result + :rtype: bytes + """ + padded = f"{v}{'=' * divmod(len(v), 4)[1]}" + return base64.urlsafe_b64decode(padded) diff --git a/pyeudiw/jwt/verification.py b/pyeudiw/jwt/verification.py index e69de29b..89888aa2 100644 --- a/pyeudiw/jwt/verification.py +++ b/pyeudiw/jwt/verification.py @@ -0,0 +1,30 @@ +from pyeudiw.jwk import JWK +from pyeudiw.jwt import JWSHelper +from pyeudiw.jwt.exceptions import JWSVerificationError +from pyeudiw.jwt.utils import decode_jwt_payload +from pyeudiw.tools.utils import iat_now + + +def verify_jws_with_key(jws: str, key: JWK) -> None: + """ + :raises JWSVerificationError: is signature verification fails for *any* reason + """ + try: + verifier = JWSHelper(key) + verifier.verify(jws) + except Exception as e: + raise JWSVerificationError(f"error during signature verification: {e}", e) + + +def is_payload_expired(token_payload: dict) -> bool: + exp = token_payload.get("exp", None) + if not exp: + return True + if exp < iat_now(): + return True + return False + + +def is_jwt_expired(token: str) -> bool: + payalod = decode_jwt_payload(token) + return is_payload_expired(payalod) diff --git a/pyeudiw/openid4vp/interface.py b/pyeudiw/openid4vp/interface.py new file mode 100644 index 00000000..602804eb --- /dev/null +++ b/pyeudiw/openid4vp/interface.py @@ -0,0 +1,46 @@ +from pyeudiw.jwk import JWK +from pyeudiw.jwt.parse import KeyIdentifier_T + + +class VpTokenParser: + """VpTokenParser is an interface that specify that an object is able to + extract verifiable credentials from a VP token. + """ + def get_credentials(self) -> dict: + raise NotImplementedError + + def get_issuer_name(self) -> str: + raise NotImplementedError + + def get_signing_key(self) -> dict | KeyIdentifier_T: + """ + :returns: a public key either as a dictionary or as an identifier + (kid string) of a public key as seen in header + :rtype: dict | str + """ + raise NotImplementedError + + +class VpTokenVerifier: + """VpTokenVerifier is an interface that specify that an object is able to + verify a vp token. + The interface supposes that the verification process requires a public + key (os the token issuer) + """ + def is_expired(self) -> bool: + raise NotImplementedError + + def is_revoked(self) -> bool: + """ + :returns: if the credential is revoked + """ + raise NotImplementedError + + def is_active(self) -> bool: + return (not self.is_expired()) and (not self.is_revoked()) + + def verify_signature(self, public_key: JWK) -> None: + """ + :raises [InvalidSignatureException]: + """ + raise NotImplementedError diff --git a/pyeudiw/openid4vp/nuovo_proposta.py b/pyeudiw/openid4vp/nuovo_proposta.py deleted file mode 100644 index 91e1a1a8..00000000 --- a/pyeudiw/openid4vp/nuovo_proposta.py +++ /dev/null @@ -1,127 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from jwcrypto.common import base64url_decode, json_decode -from sd_jwt.common import SDJWTCommon -from sd_jwt.verifier import SDJWTVerifier - -from pyeudiw.jwk import JWK -from pyeudiw.jwt import JWSHelper -from pyeudiw.jwt.schemas.jwt import UnverfiedJwt -from pyeudiw.jwt.utils import unsafe_parse_jws -from pyeudiw.openid4vp.vp_sd_jwt_kb import VerifierChallenge -from pyeudiw.sd_jwt.schema import is_sd_jwt_kb_format -from pyeudiw.tools.utils import iat_now -from pyeudiw.trust.interface import IssuerTrustEvaluator, TrustEvaluator - - -class VpTokenParser: - def get_credentials(self) -> dict: - raise NotImplementedError - - def get_issuer_name(self) -> str: - raise NotImplementedError - - def get_signing_key(self) -> dict | str: - """ - :returns: a public key or an identifier of a public key as seen in header - """ - raise NotImplementedError - - -class VpTokenVerifier: - def is_expired(self) -> bool: - raise NotImplementedError - - def is_revoked(self) -> bool: - """ - :returns: if the credential is revoked - """ - raise NotImplementedError - - def is_active(self) -> bool: - return (not self.is_expired()) and (not self.is_revoked()) - - def verify_signature(self, public_key: JWK) -> None: - """ - :raises [InvalidSignatureException]: - """ - return - - -class VpVcSdJwtParserVerifier(VpTokenParser, VpTokenVerifier): - def __init__(self, sdjwtkb: str, verifier_id: Optional[str] = None, verifier_nonce: Optional[str] = None): - self.sdjwtkb = sdjwtkb - if not is_sd_jwt_kb_format(sdjwtkb): - raise ValueError(f"input [sdjwtkb]={sdjwtkb} is not an sd-jwt with key binding: maybe it is a regular jwt or key binding jwt is missing?") - self.verifier_id = verifier_id - self.verifier_nonce = verifier_nonce - # precomputed values - self._issuer_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") - self._encoded_disclosures: list[str] = [] - self._disclosures: list[dict] = [] - self._kb_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") - self._post_init_evaluate_precomputed_values() - - def _post_init_evaluate_precomputed_values(self): - iss_jwt, *disclosures, kb_jwt = self.sdjwtkb.split(SDJWTCommon.COMBINED_SERIALIZATION_FORMAT_SEPARATOR) - self._encoded_disclosures = disclosures - self._disclosures = [json_decode(base64url_decode(disc)) for disc in disclosures] - self._issuer_jwt = unsafe_parse_jws(iss_jwt) - self._kb_jwt = unsafe_parse_jws(kb_jwt) - - def get_issuer_name(self) -> str: - iss = self._issuer_jwt.payload.get("iss", None) - if not iss: - raise Exception("missing required information in token paylaod: [iss]") - - def get_credentials(self) -> dict: - # TODO: fa un sacco di copia incolla da SDJWTVerifier - raise NotImplementedError("TODO") - - def get_signing_key(self) -> dict | str: - # TODO: usa SOLO l'header del token -> la parte di match è fatta FUORI dalla classe - if (maybe_kid := self._issuer_jwt.header.get("kid", None)): - return maybe_kid - JWSHelper - if (maybe_trust_chain := self._issuer_jwt.header.get("trust_chain", None)): - return qualcosa che prende la chiave dalla trust chian - # ?????? - pass - - def is_revoked(self) -> bool: - return False - - def is_expired(self) -> bool: - exp = self._issuer_jwt.payload.get("exp", None) - if not exp: - return True - if exp < iat_now(): - return True - return False - - def verify_signature(self, public_key: JWK) -> None: - # TODO: usa questa PPK per fare la verifica delle public keys - # ??????: fa la verifica del kb jwt? dove? - - - -@dataclass -class IdeaNuovoVpVerifier(VpTokenVerifier): - trust_model: IssuerTrustEvaluator - challenge: VerifierChallenge - - def get_credentials(self, vc_sdjwt: str) -> dict: - # implementazione minimale - verifier = SDJWTVerifier( - vc_sdjwt, - self.trust_model.get_verified_key, - self.challenge.aud, - self.challenge.nonce - ) - return verifier.get_verified_payload() - - -class EmptyVpVerifier: - def get_verified_credential(self, token: str) -> dict: - return {} diff --git a/pyeudiw/openid4vp/vp_sd_jwt_vc.py b/pyeudiw/openid4vp/vp_sd_jwt_vc.py new file mode 100644 index 00000000..f9856dbd --- /dev/null +++ b/pyeudiw/openid4vp/vp_sd_jwt_vc.py @@ -0,0 +1,39 @@ +from typing import Optional + +from pyeudiw.jwk import JWK +from pyeudiw.jwt.parse import KeyIdentifier_T, extract_key_identifier +from pyeudiw.jwt.verification import is_jwt_expired +from pyeudiw.openid4vp.interface import VpTokenParser, VpTokenVerifier +from pyeudiw.sd_jwt.schema import is_sd_jwt_kb_format +from pyeudiw.sd_jwt.sd_jwt import SdJwt + + +class VpVcSdJwtParserVerifier(VpTokenParser, VpTokenVerifier): + def __init__(self, token: str, verifier_id: Optional[str] = None, verifier_nonce: Optional[str] = None): + self.token = token + if not is_sd_jwt_kb_format(token): + raise ValueError(f"input [token]={token} is not an sd-jwt with key binding: maybe it is a regular jwt or key binding jwt is missing?") + self.verifier_id = verifier_id + self.verifier_nonce = verifier_nonce + # precomputed values + self.sdjwt = SdJwt(self.token) + + def get_issuer_name(self) -> str: + iss = self.sdjwt.issuer_jwt.payload.get("iss", None) + if not iss: + raise Exception("missing required information in token paylaod: [iss]") + + def get_credentials(self) -> dict: + return self.sdjwt.get_disclosed_claims() + + def get_signing_key(self) -> JWK | KeyIdentifier_T: + return extract_key_identifier(self.sdjwt.issuer_jwt.header) + + def is_revoked(self) -> bool: + return False + + def is_expired(self) -> bool: + return is_jwt_expired(self.sdjwt.issuer_jwt) + + def verify_signature(self, public_key: JWK) -> None: + return self.sdjwt.verify_issuer_jwt(public_key) diff --git a/pyeudiw/openid4vp/vp_void.py b/pyeudiw/openid4vp/vp_void.py new file mode 100644 index 00000000..9e78275d --- /dev/null +++ b/pyeudiw/openid4vp/vp_void.py @@ -0,0 +1,41 @@ +from pyeudiw.jwk import JWK +from pyeudiw.openid4vp.interface import VpTokenParser, VpTokenVerifier + + +class VpParserVoid(VpTokenParser): + """Default implementation of VpTokenParser. This should be used only + for mocking and testing purposes only. + """ + def get_credentials(self) -> dict: + return {} + + def get_issuer_name(self) -> str: + return "" + + def get_signing_key(self) -> dict | str: + """ + :returns: a public key or an identifier of a public key as seen in header + """ + return "" + + +class VpVerifierVoid(VpTokenVerifier): + """Default implementation of VpTokenVerifier. This should be used only + for mocking and testing purposes only. + """ + def is_expired(self) -> bool: + return False + + def is_revoked(self) -> bool: + return False + + def verify_signature(self, public_key: JWK) -> None: + return + + +class VpParserVerifierVoid(VpParserVoid, VpVerifierVoid): + """Default implementation of VpTokenParser and VpTokenVerifier. This should be + used only for mocking and testing purposes only. + The function always returnm "zero value" for all its methods. + """ + pass diff --git a/pyeudiw/satosa/default/response_handler.py b/pyeudiw/satosa/default/response_handler.py index 3ef9155c..278ad348 100644 --- a/pyeudiw/satosa/default/response_handler.py +++ b/pyeudiw/satosa/default/response_handler.py @@ -1,3 +1,4 @@ +from copy import deepcopy import datetime import hashlib import json @@ -9,22 +10,22 @@ from satosa.internal import AuthenticationInformation, InternalData from satosa.response import Redirect +from pyeudiw.jwk import JWK from pyeudiw.openid4vp.authorization_response import AuthorizeResponseDirectPost, AuthorizeResponsePayload from pyeudiw.openid4vp.exceptions import InvalidVPToken, KIDNotFound -from pyeudiw.openid4vp.nuovo_proposta import VpTokenVerifier -from pyeudiw.openid4vp.utils import infer_vp_iss, infer_vp_typ, infer_vp_header_claim -from pyeudiw.openid4vp.vp import SUPPORTED_VC_TYPES, Vp -from pyeudiw.openid4vp.vp_mock import MockVpVerifier +from pyeudiw.openid4vp.interface import VpTokenParser, VpTokenVerifier +from pyeudiw.openid4vp.vp import Vp +from pyeudiw.openid4vp.vp_sd_jwt_vc import VpVcSdJwtParserVerifier from pyeudiw.openid4vp.vp_sd_jwt import VpSdJwt -from pyeudiw.openid4vp.vp_sd_jwt_kb import VpVcSdJwtKbVerifier, VpVerifier from pyeudiw.satosa.exceptions import (AuthorizeUnmatchedResponse, BadRequestError, FinalizedSessionError, InvalidInternalStateError, NotTrustedFederationError, HTTPError) from pyeudiw.satosa.interfaces.response_handler import ResponseHandlerInterface from pyeudiw.satosa.utils.response import JsonResponse from pyeudiw.satosa.utils.trust import BackendTrust +from pyeudiw.sd_jwt.schema import VerifierChallenge from pyeudiw.storage.exceptions import StorageWriteError from pyeudiw.tools.utils import iat_now -from pyeudiw.trust import TrustEvaluationHelper +from pyeudiw.trust.interface import TrustEvaluator class ResponseHandler(ResponseHandlerInterface, BackendTrust): @@ -94,12 +95,7 @@ def _parse_http_request(self, context: Context) -> dict: return context.request - def _detect_typ_iss_vptoken(self, vp_token: str) -> tuple[str, str]: - typ = infer_vp_typ(vp_token) - iss = infer_vp_iss(vp_token) - return typ, iss - - def _retrieve_session_and_nonce_from_state(self, state: str) -> tuple[dict, str]: + def _retrieve_session_from_state(self, state: str) -> dict: """_retrieve_session_and_nonce_from_state tries to recover an authenticasion session by matching it with the state. Returns the whole session data (if found) and the nonce proposed in the authentication @@ -128,8 +124,8 @@ def _retrieve_session_and_nonce_from_state(self, state: str) -> tuple[dict, str] nonce = request_session.get("nonce", None) if not nonce: - raise InvalidInternalStateError(f"unnable to find nonce in session associated to state {state}") - return request_session, nonce + raise InvalidInternalStateError(f"unable to find nonce in session associated to state {state}: corrupted data") + return request_session def _is_same_device_flow(request_session: dict, context: Context) -> bool: initiating_session_id: str | None = request_session.get("session_id", None) @@ -164,9 +160,9 @@ def response_endpoint(self, context: Context, *args: tuple) -> Redirect | JsonRe return self._handle_400(context, _msg, HTTPError(f"error: {e}, with request: {request_dict}")) self._log_debug(context, f"response URI endpoint response with payload {authz_payload}") - request_session, nonce = {}, "" + request_session: dict = {} try: - request_session, nonce = self._retrieve_session_and_nonce_from_state(authz_payload.state) + request_session = self._retrieve_session_from_state(authz_payload.state) except AuthorizeUnmatchedResponse as e400: self._handle_400(context, e400.args[0], e400.args[1]) except InvalidInternalStateError as e500: @@ -175,65 +171,19 @@ def response_endpoint(self, context: Context, *args: tuple) -> Redirect | JsonRe self._handle_400(context, e400.args[0], HTTPError(e400.args[0])) # the flow below is a simplified algorithm of authentication response processing, where: - # (1) we don't check that presentation submission matches definition + # (1) we don't check that presentation submission matches definition (yet) # (2) we don't check that vp tokens are aligned with information declared in the presentation submission # (3) we use all disclosed claims in vp tokens to build the user identity attributes_by_issuer: dict[str, dict[str, Any]] = {} credential_issuers: list[str] = [] encoded_vps: list[str] = [authz_payload.vp_token] if isinstance(authz_payload.vp_token, str) else authz_payload.vp_token for vp_token in encoded_vps: - # -- START HERE -- - nuovo_verifier: VpTokenVerifier = self._vp_verifier_factory(authz_payload.presentation_submission) - verified_claims = nuovo_verifier.get_verifiable_credential() - _ = verified_claims # consuma le credenziali - # -- END HERE -- - # simplified algorithm steps - # (a): verify that vp is vc+sd-jwt - # (b): verify that issuer jwt is valid (ok signature, not expired, etc.) - # (c): verify that binded key is valid (contains nonce above, ok signature, not expired) - # (d): extract claims for vp in order to build user identity - try: - typ, iss = self._detect_typ_iss_vptoken(vp_token) - except Exception as e: - return self._handle_400( - context, - "DirectPostResponse content parse and validation error. Single VPs are faulty.", - e - ) - # self._handle_credential_trust(context, vp) - credential_issuers.append(iss) - trust_chain = {"trust_chain": infer_vp_header_claim(vp_token, claim_name="trust_chain")} - trust_chain_helper = TrustEvaluationHelper( - self.db_engine, - httpc_params=self.config['network']['httpc_params'], - **trust_chain - ) - issuers_jwks = trust_chain_helper.get_trusted_jwks(ResponseHandler._ACCEPTED_ISSUER_METADATA_TYPE) - trusted_jwks_by_kid: dict[str, dict] = {jwk["kid"]: jwk for jwk in issuers_jwks} - if typ not in SUPPORTED_VC_TYPES: - self._log_warning(context, f"missing or unrecognized typ={typ}; skipping vp token={vp_token}") - continue - verifier: VpVerifier | None = None - match typ: - case "JWT": - verifier = MockVpVerifier(vp_token) - case "wallet-attestation+jwt": - verifier = MockVpVerifier(vp_token) - case "vc+sd-jwt": - verifier = VpVcSdJwtKbVerifier(vp_token, self.client_id, nonce, trusted_jwks_by_kid) - case "mdoc_cbor": - verifier = MockVpVerifier(vp_token) - case unrecognized_typ: - return self._handle_400(context, f"unable to process vp token with typ={unrecognized_typ}") - if verifier is None: - return self._handle_500(context, "invalid state", Exception("invalid state")) - # TODO: revocation check here - # verifier.check_revocation_status() - try: - verifier.verify() - except InvalidVPToken as e: - return self._handle_400(context, "invalid vp token", e) - claims = verifier.parse_digital_credential() + # verify vp token and extract user information + token_parser, token_verifier = self._vp_verifier_factory(authz_payload.presentation_submission, vp_token, request_session) + pub_jwk = _find_vp_token_key(token_parser, self.trust_evaluator) + token_verifier.verify_signature(pub_jwk) + claims = token_parser.get_credentials() + iss = token_parser.get_issuer_name() attributes_by_issuer[iss] = claims self._log_debug(context, f"disclosed claims {claims} from issuer {iss}") all_attributes = self._extract_all_user_attributes(attributes_by_issuer) @@ -336,8 +286,27 @@ def _translate_response(self, response: dict, issuer: str, context: Context) -> internal_resp.subject_id = sub return internal_resp - def _vp_verifier_factory(self, presentation_submission: dict, token: str) -> VpTokenVerifier: - # Idea: se vc+sd-jwt → IdeaNuovoVpVerifier - # PROBLEM: come faccio a fare dependency injection (in questo caso, della challenge?) - # ci devo pensare con calma, non sono sicurissimo di saperlo ora - raise NotImplementedError + def _vp_verifier_factory(self, presentation_submission: dict, token: str, session_data: dict) -> tuple[VpTokenParser, VpTokenVerifier]: + # TODO: la funzione dovrebbe confumare la presentation submission per sapere quale token + # ritornare - per ora viene sieme ritornata l'unica implementazione possibile + challenge = self._get_verifier_challenge(session_data) + token_processor = VpVcSdJwtParserVerifier(token, challenge["aud"], challenge["nonce"]) + return (token_processor, deepcopy(token_processor)) + + def _get_verifier_challenge(self, session_data: dict) -> VerifierChallenge: + return {"aud": self.client_id, "nonce": session_data["nonce"]} + + +def _find_vp_token_key(token_parser: VpTokenParser, key_source: TrustEvaluator) -> JWK: + issuer = token_parser.get_issuer_name() + trusted_pub_keys = key_source.get_public_keys(issuer) + verification_key = token_parser.get_signing_key() + if isinstance(verification_key, str): + # search by kid + kid = verification_key + pub_jwks = [key for key in trusted_pub_keys if key.get("kid", "") == kid] + if len(pub_jwks) != 1: + raise Exception(f"no unique valid trusted key with kid={kid} for issuer {issuer}") + return JWK(pub_jwks[0]) + else: + raise NotImplementedError("TODO: matching of public key (ex. from x5c) with keys from trust source") diff --git a/pyeudiw/sd_jwt/__init__.py b/pyeudiw/sd_jwt/__init__.py index 8c79665e..8966e0f1 100644 --- a/pyeudiw/sd_jwt/__init__.py +++ b/pyeudiw/sd_jwt/__init__.py @@ -20,6 +20,7 @@ from json import dumps, loads import jwcrypto +import jwcrypto.jwk from typing import Any from cryptojwt.jwk.rsa import RSAKey diff --git a/pyeudiw/sd_jwt/exceptions.py b/pyeudiw/sd_jwt/exceptions.py index d46299db..ee7489d8 100644 --- a/pyeudiw/sd_jwt/exceptions.py +++ b/pyeudiw/sd_jwt/exceptions.py @@ -1,2 +1,10 @@ class UnknownCurveNistName(Exception): pass + + +class InvalidKeyBinding(Exception): + pass + + +class UnsupportedSdAlg(Exception): + pass diff --git a/pyeudiw/sd_jwt/schema.py b/pyeudiw/sd_jwt/schema.py index 4403fb98..2970fd4d 100644 --- a/pyeudiw/sd_jwt/schema.py +++ b/pyeudiw/sd_jwt/schema.py @@ -1,7 +1,8 @@ import re -from typing import Dict, Literal, Optional, TypeVar +from typing import Dict, Literal, Optional, TypeVar, TypedDict +from typing_extensions import Self -from pydantic import BaseModel, HttpUrl, field_validator +from pydantic import BaseModel, HttpUrl, field_validator, model_validator from pyeudiw.jwk.schemas.public import JwkSchema @@ -41,6 +42,12 @@ def validate_typ(cls, v: str) -> str: raise ValueError(f"header parameter [typ] must be '{_IDENTIFYING_VC_TYP}', found instead '{v}'") return v + @model_validator(mode="after") + def check_typ_when_not_x5c(self) -> Self: + if (not self.x5c) and (not self.kid): + raise ValueError("[kid] must be defined if [x5c] claim is not defined") + return self + class _StatusAssertionSchema(BaseModel): credential_hash_alg: str @@ -106,3 +113,8 @@ class KeyBindingJwtPayload(BaseModel): aud: str nonce: str sd_hash: str + + +class VerifierChallenge(TypedDict): + aud: str + nonce: str diff --git a/pyeudiw/sd_jwt/sd_jwt.py b/pyeudiw/sd_jwt/sd_jwt.py new file mode 100644 index 00000000..00e6f61e --- /dev/null +++ b/pyeudiw/sd_jwt/sd_jwt.py @@ -0,0 +1,200 @@ +from hashlib import sha256 +import json +from typing import Any, Callable, TypeVar +import sd_jwt.common as sd_jwtcommon +from sd_jwt.common import SDJWTCommon + +from pyeudiw.jwk import JWK +from pyeudiw.jwt.parse import unsafe_parse_jws +from pyeudiw.jwt.utils import base64_urldecode, base64_urlencode +from pyeudiw.jwt.verification import verify_jws_with_key +from pyeudiw.openid4vp.vp_sd_jwt_kb import VerifierChallenge +from pyeudiw.sd_jwt.exceptions import InvalidKeyBinding, UnsupportedSdAlg +from pyeudiw.sd_jwt.schema import is_sd_jwt_format, is_sd_jwt_kb_format +from pyeudiw.jwt.schemas.jwt import UnverfiedJwt + + +_JsonTypes = dict | list | str | int | float | bool | None +_JsonTypes_T = TypeVar('_JsonTypes_T', bound=_JsonTypes) + +DEFAULT_SD_ALG = "sha-256" +DIGEST_ALG_KEY = sd_jwtcommon.DIGEST_ALG_KEY +FORMAT_SEPARATOR = SDJWTCommon.COMBINED_SERIALIZATION_FORMAT_SEPARATOR +SD_DIGESTS_KEY = sd_jwtcommon.SD_DIGESTS_KEY +SD_LIST_PREFIX = sd_jwtcommon.SD_LIST_PREFIX + +SUPPORTED_SD_ALG_FN: dict[str, Callable[[str], str]] = { + "sha-256": lambda s: base64_urlencode(sha256(s.encode("ascii")).digest()) +} + + +class SdJwt: + """ + SdJwt is an utility class to easly [arse and verify sd jwt. + All class attributes are intended to be read only + """ + + def __init__(self, token: str): + if not is_sd_jwt_format(token): + raise ValueError(f"input [token]={token} is not an sd-jwt with: maybe it is a regular jwt?") + self.token = token + # precomputed values + self.token_without_kb: str = "" + self.issuer_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") + self.disclosures: list[str] = [] + self.holder_kb: UnverfiedJwt | None = None + self._post_init_precomputed_values() + + def _post_init_precomputed_values(self): + iss_jwt, *disclosures, kb_jwt = self.token.split(FORMAT_SEPARATOR) + self.token_without_kb = iss_jwt = iss_jwt + FORMAT_SEPARATOR.join(disclosures) + self.issuer_jwt = unsafe_parse_jws(iss_jwt) + self.disclosures = disclosures + if kb_jwt: + self.holder_kb = unsafe_parse_jws(iss_jwt) + # TODO: schema validations(?) + + def get_confirmation_key(self) -> dict: + cnf: dict = self.issuer_jwt.payload.get("cnf", {}).get("jwk", {}) + if not cnf: + raise ValueError("missing confermation (cnf) key from issuer payload claims") + return cnf + + def get_disclosed_claims(self) -> dict: + return _extract_claims_from_payload(self.issuer_jwt.payload, self.disclosures, SUPPORTED_SD_ALG_FN[self.get_sd_alg()]) + + def get_sd_alg(self) -> str: + return self.issuer_jwt.payload.get("_sd_alg", DEFAULT_SD_ALG) + + def has_key_binding(self) -> bool: + return self.holder_kb is not None + + def verify_issuer_jwt(self, key: JWK) -> None: + verify_jws_with_key(self.issuer_jwt.jwt, key) + + def verify_holder_kb(self, challenge: VerifierChallenge) -> None: + """ + Checks validity of holder key binding. + This procedurre always passes when no key binding is used + + :raises UnsupportedSdAlg: if verification fails due to an unkown _sd_alf + :raises InvalidKeyBinding: if the verification fails for a known reason + """ + if not self.has_key_binding(): + return + _verify_key_binding(self.token_without_kb, self.get_sd_alg(), + self.holder_kb, challenge) + self.verify_holder_kb_signature() + + def verify_holder_kb_signature(self) -> None: + if not self.has_key_binding(): + return + cnf = self.get_confirmation_key() + verify_jws_with_key(self.holder_kb.jwt, JWK(cnf)) + + +class SdJwtKb(SdJwt): + + def __init__(self, token: str): + if not is_sd_jwt_kb_format(token): + raise ValueError(f"input [token]={token} is not an sd-jwt with key binding with: maybe it is a regular jwt?") + super().__init__(token) + if not self.holder_kb: + raise ValueError("missing key binding jwt") + + +def _verify_challenge(hkb: UnverfiedJwt, challenge: VerifierChallenge): + if not (obt := hkb.payload.get("aud", None)) != (exp := challenge["aud"]): + raise InvalidKeyBinding(f"challene audience {exp} due not match obtained audience {obt}") + if not (obt := hkb.payload.get("nonce", None)) != (exp := challenge["nonce"]): + raise InvalidKeyBinding(f"challene nonce {exp} due not match obtained nonce {obt}") + + +def _verify_sd_hash(token_without_hkb: str, sd_hash_alg: str, expected_digest: str): + hash_fn = SUPPORTED_SD_ALG_FN.get(sd_hash_alg, None) + if not hash_fn: + raise UnsupportedSdAlg(f"unsupported sd_alg: {sd_hash_alg}") + if expected_digest != (obt_digest := hash_fn(token_without_hkb)): + raise InvalidKeyBinding(f"sd-jwt digest {obt_digest} does not match expected digest {expected_digest}") + + +def _verify_key_binding(token_without_hkb: str, sd_hash_alg: str, hkb: UnverfiedJwt, challenge: VerifierChallenge): + _verify_challenge(hkb, challenge) + _verify_sd_hash(token_without_hkb, sd_hash_alg, hkb.payload.get("sd_hash", "")) + + +def _disclosures_to_hash_mappings(disclosures: list[str], sd_alg: Callable[[str], str]) -> tuple[dict[str, str], dict[str, Any]]: + """ + :returns: in order + (i) hash_to_disclosure, a map: digest -> raw disclosure (base64 encoded) + (ii) hash_to_dec_disclosure, a map: digest -> decoded disclosure + :rtype: tuple[dict[str, str], dict[str, Any]] + """ + hash_to_disclosure: dict[str, str] = {} + hash_to_dec_disclosure: dict[str, Any] = {} + for disclosure in disclosures: + decoded_disclosure = json.loads(base64_urldecode(disclosure).decode("utf-8")) + digest = sd_alg(disclosure) + if digest in hash_to_dec_disclosure: + raise ValueError(f"duplicate disclosure for digest {digest}") + hash_to_dec_disclosure[digest] = decoded_disclosure + hash_to_disclosure[digest] = disclosure + return hash_to_disclosure, hash_to_dec_disclosure + + +def _extract_claims_from_payload(payload: dict, disclosures: list[str], sd_alg: Callable[[str], str]) -> dict: + hash_to_disclosure, hash_to_dec_disclosure = _disclosures_to_hash_mappings(disclosures, sd_alg) + return _unpack_claims(payload, hash_to_dec_disclosure, sd_alg, []) + + +def _is_element_leaf(element: Any) -> bool: + return (type(element) is dict and len(element) == 1 and SD_LIST_PREFIX in element + and type(element[SD_LIST_PREFIX]) is str) + + +def _unpack_json_array(claims: list, decoded_disclosures_by_digest: dict[str, Any], sd_alg: Callable[[str], str], proceessed_digests: list[str]) -> list: + result = [] + for element in claims: + if _is_element_leaf(element): + digest: str = element[SD_LIST_PREFIX] + if digest in decoded_disclosures_by_digest: + _, value = decoded_disclosures_by_digest[digest] + result.append(_unpack_claims(value, decoded_disclosures_by_digest, sd_alg, proceessed_digests)) + else: + result.append(_unpack_claims(element, decoded_disclosures_by_digest, sd_alg, proceessed_digests)) + return result + + +def _unpack_json_dict(claims: dict, decoded_disclosures_by_digest: dict[str, Any], sd_alg: Callable[[str], str], proceessed_digests: list[str]) -> dict: + # First, try to figure out if there are any claims to be + # disclosed in this dict. If so, replace them by their + # disclosed values. + filtered_unpacked_claims = {} + for k, v in claims.items(): + if k != SD_DIGESTS_KEY and k != DIGEST_ALG_KEY: + filtered_unpacked_claims[k] = _unpack_claims(v, decoded_disclosures_by_digest, sd_alg) + + for disclosed_digests in claims.get(SD_DIGESTS_KEY, []): + if disclosed_digests in proceessed_digests: + raise ValueError(f"duplicate hash found in SD-JWT: {disclosed_digests}") + proceessed_digests.append(disclosed_digests) + + if disclosed_digests in decoded_disclosures_by_digest: + _, key, value = decoded_disclosures_by_digest[disclosed_digests] + if key in filtered_unpacked_claims: + raise ValueError( + f"duplicate key found when unpacking disclosed claim: '{key}' in {filtered_unpacked_claims}; this is not allowed." + ) + unpacked_value = _unpack_claims(value, decoded_disclosures_by_digest, sd_alg, proceessed_digests) + filtered_unpacked_claims[key] = unpacked_value + return filtered_unpacked_claims + + +def _unpack_claims(claims: _JsonTypes_T, decoded_disclosures_by_digest: dict[str, Any], + sd_alg: Callable[[str], str], proceessed_digests: list[str]) -> _JsonTypes_T: + if type(claims) is list: + return _unpack_json_array(claims, decoded_disclosures_by_digest, sd_alg, proceessed_digests) + elif type(claims) is dict: + return _unpack_json_dict(claims, decoded_disclosures_by_digest, sd_alg, proceessed_digests) + else: + return claims diff --git a/pyeudiw/tests/trust/default/settings.py b/pyeudiw/tests/trust/default/settings.py new file mode 100644 index 00000000..c6168c26 --- /dev/null +++ b/pyeudiw/tests/trust/default/settings.py @@ -0,0 +1,16 @@ +issuer = "https://credential-issuer.example/vct/" +issuer_jwk = { + "kty": "EC", + "kid": "MGaAh57cQghnevfWusalp0lNFXTzz2kHnkzO9wOjHq4", + "crv": "P-256", + "x": "S57KP4yGauTJJuNvO-wgWr2h_BYsatYUA1xW8Nae8i4", + "y": "66DmArglfyJODHAzZsIiPTY24gK70eeXPbpT4Nk0768" +} +issuer_vct_md = { + "issuer": issuer, + "jwks": { + "keys": [ + issuer_jwk + ] + } +} \ No newline at end of file diff --git a/pyeudiw/tests/trust/default/test_direct_trust.py b/pyeudiw/tests/trust/default/test_direct_trust.py new file mode 100644 index 00000000..dd37f95b --- /dev/null +++ b/pyeudiw/tests/trust/default/test_direct_trust.py @@ -0,0 +1,30 @@ +import json +import requests +import unittest.mock + +from pyeudiw.trust.default import DEFAULT_DIRECT_TRUST_PARAMS +from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc + +from .settings import issuer, issuer_vct_md +from .settings import issuer_jwk as expected_jwk + + +def test_direct_trust_jwk(): + jwt_vc_issuer_endpoint_response = requests.Response() + jwt_vc_issuer_endpoint_response.status_code = 200 + jwt_vc_issuer_endpoint_response.headers.update({"Content-Type": "application/json"}) + jwt_vc_issuer_endpoint_response._content = json.dumps(issuer_vct_md).encode('utf-8') + + mocked_jwk_source_patcher = unittest.mock.patch("pyeudiw.vci.jwks_provider.get_http_url") + mocked_jwk_source = mocked_jwk_source_patcher.start() + mocked_jwk_source.return_value = [ + jwt_vc_issuer_endpoint_response + ] + + trust_source = DirectTrustSdJwtVc(**DEFAULT_DIRECT_TRUST_PARAMS) + obtained_jwks = trust_source.get_public_keys(issuer) + + mocked_jwk_source_patcher.stop() + + assert len(obtained_jwks) == 1, f"expected 1 jwk, obtained {len(obtained_jwks)}" + assert expected_jwk == obtained_jwks[0] diff --git a/pyeudiw/trust/interface.py b/pyeudiw/trust/interface.py index 0d125d2c..c4239c2f 100644 --- a/pyeudiw/trust/interface.py +++ b/pyeudiw/trust/interface.py @@ -1,12 +1,3 @@ -import importlib - -from jwcrypto.jwk import JWK - -from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc -from pyeudiw.trust.exceptions import TrustConfigurationError -from pyeudiw.trust._log import _package_logger - - class TrustEvaluator: """ TrustEvaluator is an interface that defined the expected behaviour of a @@ -52,46 +43,3 @@ def get_policies(self, issuer: str) -> dict: "timeout": 6 } } - - -class IssuerTrustEvaluator: - - def __init__(self, trust_config: dict): - self.trust_configs: dict = trust_config - self.trust_methods: dict[str, object] = {} - if not self.trust_configs: - _package_logger.warning("no configured trust model, using direct trust model") - self.trust_methods["direct_trust"] = DirectTrustSdJwtVc(DEFAULT_HTTPC_PARAMS) - return - for k, v in self.trust_configs.items(): - try: - module = importlib.import_module(v["module"]) - class_type = getattr(module, v["class"]) - class_config = v["config"] - except KeyError as e: - _package_logger.critical(f"invalid trust configuration for {k}: missing mandatory fields [module] and/or [class]") - raise TrustConfigurationError(f"invalid configuration for {k}: {e}", e) - except Exception as e: - raise TrustConfigurationError(f"invalid config: {e}", e) - _package_logger.debug(f"loading {class_type} with config {class_config}") - self.trust_methods[k] = class_type(**class_config) - - def get_public_keys(self, issuer: str) -> list[dict]: - """ - yields the public cryptographic material of the issuer - - :returns: a list of jwk(s) - """ - raise NotImplementedError - - def get_metadata(self, issuer: str) -> dict: - raise NotImplementedError - - def is_revoked(self, issuer: str) -> bool: - raise NotImplementedError - - def get_policies(self, issuer: str) -> dict: - raise NotImplementedError("reserved for future uses") - - def get_verified_key(self, issuer: str, token_header: dict) -> JWK: # ← TODO: consider removal - raise NotImplementedError diff --git a/pyeudiw/x509/verify.py b/pyeudiw/x509/verify.py index d89fd59e..2c8378e2 100644 --- a/pyeudiw/x509/verify.py +++ b/pyeudiw/x509/verify.py @@ -5,6 +5,8 @@ from ssl import DER_cert_to_PEM_cert from cryptography.x509 import load_der_x509_certificate +from pyeudiw.jwk import JWK + LOG_ERROR = "x509 verification failed: {}" logger = logging.getLogger(__name__) @@ -161,3 +163,7 @@ def is_der_format(cert: bytes) -> str: except crypto.Error as e: logging.error(LOG_ERROR.format(e)) return False + + +def get_public_key_from_x509_chain(x5c: list[bytes]) -> JWK: + raise NotImplementedError("TODO") From b6fcd69bc7d75993aa921625867746357e3c1e73 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Wed, 2 Oct 2024 09:01:28 +0200 Subject: [PATCH 07/13] wip: unit tests --- example/satosa/pyeudiw_backend.yaml | 2 +- .../tests/trust/default/test_direct_trust.py | 13 ++-- pyeudiw/tests/trust/test_dyanmic.py | 4 - pyeudiw/tests/trust/test_dynamic.py | 75 +++++++++++++++++++ pyeudiw/trust/interface.py | 10 --- 5 files changed, 81 insertions(+), 23 deletions(-) delete mode 100644 pyeudiw/tests/trust/test_dyanmic.py create mode 100644 pyeudiw/tests/trust/test_dynamic.py diff --git a/example/satosa/pyeudiw_backend.yaml b/example/satosa/pyeudiw_backend.yaml index 21036cd7..b9c37621 100644 --- a/example/satosa/pyeudiw_backend.yaml +++ b/example/satosa/pyeudiw_backend.yaml @@ -119,7 +119,7 @@ config: module: pyeudiw.trust.default.direct_trust class: DirectTrustSdJwtVc config: - endpoint: /.well-known/jwt-vc-issuer + jwk_endpoint: /.well-known/jwt-vc-issuer httpc_params: connection: ssl: true diff --git a/pyeudiw/tests/trust/default/test_direct_trust.py b/pyeudiw/tests/trust/default/test_direct_trust.py index dd37f95b..e702358a 100644 --- a/pyeudiw/tests/trust/default/test_direct_trust.py +++ b/pyeudiw/tests/trust/default/test_direct_trust.py @@ -5,8 +5,8 @@ from pyeudiw.trust.default import DEFAULT_DIRECT_TRUST_PARAMS from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc -from .settings import issuer, issuer_vct_md -from .settings import issuer_jwk as expected_jwk +from pyeudiw.tests.trust.default.settings import issuer, issuer_vct_md +from pyeudiw.tests.trust.default.settings import issuer_jwk as expected_jwk def test_direct_trust_jwk(): @@ -15,16 +15,13 @@ def test_direct_trust_jwk(): jwt_vc_issuer_endpoint_response.headers.update({"Content-Type": "application/json"}) jwt_vc_issuer_endpoint_response._content = json.dumps(issuer_vct_md).encode('utf-8') - mocked_jwk_source_patcher = unittest.mock.patch("pyeudiw.vci.jwks_provider.get_http_url") - mocked_jwk_source = mocked_jwk_source_patcher.start() - mocked_jwk_source.return_value = [ - jwt_vc_issuer_endpoint_response - ] + mocked_issuer_jwt_vc_issuer_endpoint = unittest.mock.patch("pyeudiw.vci.jwks_provider.get_http_url", return_value=[jwt_vc_issuer_endpoint_response]) + mocked_issuer_jwt_vc_issuer_endpoint.start() trust_source = DirectTrustSdJwtVc(**DEFAULT_DIRECT_TRUST_PARAMS) obtained_jwks = trust_source.get_public_keys(issuer) - mocked_jwk_source_patcher.stop() + mocked_issuer_jwt_vc_issuer_endpoint.stop() assert len(obtained_jwks) == 1, f"expected 1 jwk, obtained {len(obtained_jwks)}" assert expected_jwk == obtained_jwks[0] diff --git a/pyeudiw/tests/trust/test_dyanmic.py b/pyeudiw/tests/trust/test_dyanmic.py deleted file mode 100644 index 1a8d6496..00000000 --- a/pyeudiw/tests/trust/test_dyanmic.py +++ /dev/null @@ -1,4 +0,0 @@ -def test_trust_evaluators_loader(): - config = { - } - # TODO \ No newline at end of file diff --git a/pyeudiw/tests/trust/test_dynamic.py b/pyeudiw/tests/trust/test_dynamic.py new file mode 100644 index 00000000..05afd868 --- /dev/null +++ b/pyeudiw/tests/trust/test_dynamic.py @@ -0,0 +1,75 @@ +from pyeudiw.trust.default import DEFAULT_DIRECT_TRUST_PARAMS +from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc +from pyeudiw.trust.dynamic import CombinedTrustEvaluator, dynamic_trust_evaluators_loader +from pyeudiw.trust.interface import TrustEvaluator + + +class MockTrustEvaluator(TrustEvaluator): + """Mock realization of TrustEvaluator for testing purposes only + """ + mock_jwk = { + "crv": "P-256", + "kid": "qTo9RGpuU_CSolt6GZmndLyPXJJa48up5dH1YbxVDPs", + "kty": "EC", + "use": "sig", + "x": "xu0FC3OQLgsea27rL0-d2CpVyKijjwl8tF6HB-3zLUg", + "y": "fUEsB8IrX2DgzqABfVsCody1RypAXX54fXQ1keoPP5Y" + } + + def __init__(self): + pass + + def get_public_keys(self, issuer: str) -> list[dict]: + return [ + MockTrustEvaluator.mock_jwk + ] + + def get_metadata(self, issuer: str) -> dict: + return { + "jsono_key": "json_value" + } + + def is_revoked(self, issuer: str) -> bool: + return False + + def get_policies(self, issuer: str) -> dict: + return {} + + +def test_trust_evaluators_loader(): + config = { + "mock": { + "module": "pyeudiw.tests.trust.test_dynamic", + "class": "MockTrustEvaluator", + "config": {} + }, + "direct_trust": { + "module": "pyeudiw.trust.default.direct_trust", + "class": "DirectTrustSdJwtVc", + "config": { + "jwk_endpoint": "/.well-known/jwt-vc-issuer", + "httpc_params": { + "connection": { + "ssl": True + }, + "session": { + "timeout": 6 + } + } + } + } + } + trust_sources = dynamic_trust_evaluators_loader(config) + assert "mock" in trust_sources + assert trust_sources["mock"].__class__.__name__ == "MockTrustEvaluator" + assert "direct_trust" in trust_sources + assert trust_sources["direct_trust"].__class__.__name__ == "DirectTrustSdJwtVc" + + +def test_combined_trust_evaluator(): + evaluators = { + "mock": MockTrustEvaluator(), + "direct_trust": DirectTrustSdJwtVc(**DEFAULT_DIRECT_TRUST_PARAMS) + } + combined = CombinedTrustEvaluator(evaluators) + assert MockTrustEvaluator.mock_jwk in combined.get_public_keys("mock_issuer") diff --git a/pyeudiw/trust/interface.py b/pyeudiw/trust/interface.py index c4239c2f..e38f8756 100644 --- a/pyeudiw/trust/interface.py +++ b/pyeudiw/trust/interface.py @@ -33,13 +33,3 @@ def is_revoked(self, issuer: str) -> bool: def get_policies(self, issuer: str) -> dict: raise NotImplementedError("reserved for future uses") - - -DEFAULT_HTTPC_PARAMS = { - "connection": { - "ssl": True - }, - "session": { - "timeout": 6 - } -} From 8459a971a9ea90e4e14836ec100686878f80e10c Mon Sep 17 00:00:00 2001 From: Zicchio Date: Wed, 2 Oct 2024 09:36:46 +0200 Subject: [PATCH 08/13] chore: renamed function --- pyeudiw/federation/statements.py | 12 ++++++------ pyeudiw/federation/trust_chain_validator.py | 6 +++--- pyeudiw/jwk/__init__.py | 3 +-- pyeudiw/jwt/utils.py | 4 ++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/pyeudiw/federation/statements.py b/pyeudiw/federation/statements.py index 4cabb50c..f8cf9fe2 100644 --- a/pyeudiw/federation/statements.py +++ b/pyeudiw/federation/statements.py @@ -16,7 +16,7 @@ from pydantic import ValidationError from pyeudiw.jwt.utils import decode_jwt_payload, decode_jwt_header from pyeudiw.jwt import JWSHelper -from pyeudiw.jwk import find_jwk +from pyeudiw.jwk import find_jwk_by_kid from pyeudiw.tools.utils import get_http_url import logging @@ -171,7 +171,7 @@ def validate_by(self, ec: dict) -> bool: f"{self.header.get('kid')} not found in {ec.jwks}" ) - _jwk = find_jwk(_kid, ec.jwks) + _jwk = find_jwk_by_kid(_kid, ec.jwks) # verify signature jwsh = JWSHelper(_jwk) @@ -211,7 +211,7 @@ def validate_by_its_issuer(self) -> bool: return False # verify signature - _jwk = find_jwk(_kid, ec.jwks) + _jwk = find_jwk_by_kid(_kid, ec.jwks) jwsh = JWSHelper(_jwk) payload = jwsh.verify(self.jwt) self.is_valid = True @@ -314,7 +314,7 @@ def validate_by_itself(self) -> bool: f"{_kid} not found in {self.jwks}") # pragma: no cover # verify signature - _jwk = find_jwk(_kid, self.jwks) + _jwk = find_jwk_by_kid(_kid, self.jwks) jwsh = JWSHelper(_jwk) jwsh.verify(self.jwt) self.is_valid = True @@ -539,7 +539,7 @@ def validate_descendant_statement(self, jwt: str) -> bool: f"{_kid} not found in {self.jwks}") # verify signature - _jwk = find_jwk(_kid, self.jwks) + _jwk = find_jwk_by_kid(_kid, self.jwks) jwsh = JWSHelper(_jwk) payload = jwsh.verify(jwt) @@ -565,7 +565,7 @@ def validate_by_superior_statement(self, jwt: str, ec: 'EntityStatement') -> str ec.validate_by_itself() ec.validate_descendant_statement(jwt) _jwks = get_federation_jwks(payload) - _jwk = find_jwk(self.header["kid"], _jwks) + _jwk = find_jwk_by_kid(self.header["kid"], _jwks) jwsh = JWSHelper(_jwk) payload = jwsh.verify(self.jwt) diff --git a/pyeudiw/federation/trust_chain_validator.py b/pyeudiw/federation/trust_chain_validator.py index e91b45c4..9e499c05 100644 --- a/pyeudiw/federation/trust_chain_validator.py +++ b/pyeudiw/federation/trust_chain_validator.py @@ -15,7 +15,7 @@ InvalidEntityStatement ) -from pyeudiw.jwk import find_jwk +from pyeudiw.jwk import find_jwk_by_kid from pyeudiw.jwk.exceptions import KidNotFoundError, InvalidKid logger = logging.getLogger(__name__) @@ -127,7 +127,7 @@ def validate(self) -> bool: es_header = decode_jwt_header(last_element) es_payload = decode_jwt_payload(last_element) - ta_jwk = find_jwk( + ta_jwk = find_jwk_by_kid( es_header.get("kid", None), self.trust_anchor_jwks ) @@ -165,7 +165,7 @@ def validate(self) -> bool: st_payload = decode_jwt_payload(st) try: - jwk = find_jwk( + jwk = find_jwk_by_kid( st_header.get("kid", None), fed_jwks ) except (KidNotFoundError, InvalidKid): diff --git a/pyeudiw/jwk/__init__.py b/pyeudiw/jwk/__init__.py index b0624417..c5ec9030 100644 --- a/pyeudiw/jwk/__init__.py +++ b/pyeudiw/jwk/__init__.py @@ -153,8 +153,7 @@ def jwk_form_dict(key: dict, hash_func: str = "SHA-256") -> RSAJWK | ECJWK: return ECJWK(key, hash_func, ec_crv) -# TODO: rename by find_jwk_by_kid -def find_jwk(kid: str, jwks: list[dict], as_dict: bool = True) -> dict | JWK: +def find_jwk_by_kid(kid: str, jwks: list[dict], as_dict: bool = True) -> dict | JWK: """ Find the JWK with the indicated kid in the jwks list. diff --git a/pyeudiw/jwt/utils.py b/pyeudiw/jwt/utils.py index 170b521d..a6c0c70e 100644 --- a/pyeudiw/jwt/utils.py +++ b/pyeudiw/jwt/utils.py @@ -3,7 +3,7 @@ import re from typing import Dict -from pyeudiw.jwk import find_jwk +from pyeudiw.jwk import find_jwk_by_kid from pyeudiw.jwt.exceptions import JWTInvalidElementPosition # jwt regexp pattern is non terminating, hence it match jwt, sd-jwt and sd-jwt with kb @@ -84,7 +84,7 @@ def get_jwk_from_jwt(jwt: str, provider_jwks: Dict[str, dict]) -> dict: if isinstance(provider_jwks, dict) and provider_jwks.get('keys'): provider_jwks = provider_jwks['keys'] - return find_jwk(kid, provider_jwks) + return find_jwk_by_kid(kid, provider_jwks) def is_jwt_format(jwt: str) -> bool: From af4694f8023a3c3ab802a979208f267340b5553a Mon Sep 17 00:00:00 2001 From: Zicchio Date: Wed, 2 Oct 2024 11:39:43 +0200 Subject: [PATCH 09/13] wip: unit tests, fixes --- pyeudiw/openid4vp/vp_sd_jwt_kb.py | 4 +- pyeudiw/sd_jwt/sd_jwt.py | 29 ++-- pyeudiw/tests/sd_jwt/test_sdjwt.py | 223 +++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 pyeudiw/tests/sd_jwt/test_sdjwt.py diff --git a/pyeudiw/openid4vp/vp_sd_jwt_kb.py b/pyeudiw/openid4vp/vp_sd_jwt_kb.py index 1c20d4f0..3b2ddd4c 100644 --- a/pyeudiw/openid4vp/vp_sd_jwt_kb.py +++ b/pyeudiw/openid4vp/vp_sd_jwt_kb.py @@ -9,7 +9,7 @@ from pyeudiw.jwk import JWK from pyeudiw.jwt import JWSHelper -from pyeudiw.jwt.utils import unsafe_parse_jws +from pyeudiw.jwt.parse import unsafe_parse_jws from pyeudiw.jwt.schemas.jwt import UnverfiedJwt from pyeudiw.openid4vp.exceptions import InvalidVPKeyBinding, InvalidVPSignature, KIDNotFound, VPSchemaException from pyeudiw.openid4vp.verifier import VpVerifier @@ -126,7 +126,7 @@ def __str__(self) -> str: def _verify_jws_with_key(issuer_jwt: str, issuer_key: JWK): - try: + try: verifier = JWSHelper(issuer_key) except Exception as e: raise InvalidVPSignature(f"failed signature verification of issuer-jwt: invalid issuer key due to cause: {e}") diff --git a/pyeudiw/sd_jwt/sd_jwt.py b/pyeudiw/sd_jwt/sd_jwt.py index 00e6f61e..e41cffac 100644 --- a/pyeudiw/sd_jwt/sd_jwt.py +++ b/pyeudiw/sd_jwt/sd_jwt.py @@ -47,11 +47,11 @@ def __init__(self, token: str): def _post_init_precomputed_values(self): iss_jwt, *disclosures, kb_jwt = self.token.split(FORMAT_SEPARATOR) - self.token_without_kb = iss_jwt = iss_jwt + FORMAT_SEPARATOR.join(disclosures) + self.token_without_kb = iss_jwt + FORMAT_SEPARATOR + ''.join(disc + FORMAT_SEPARATOR for disc in disclosures) self.issuer_jwt = unsafe_parse_jws(iss_jwt) self.disclosures = disclosures if kb_jwt: - self.holder_kb = unsafe_parse_jws(iss_jwt) + self.holder_kb = unsafe_parse_jws(kb_jwt) # TODO: schema validations(?) def get_confirmation_key(self) -> dict: @@ -60,9 +60,18 @@ def get_confirmation_key(self) -> dict: raise ValueError("missing confermation (cnf) key from issuer payload claims") return cnf + def get_disclosures(self) -> list[str]: + return self.disclosures + def get_disclosed_claims(self) -> dict: return _extract_claims_from_payload(self.issuer_jwt.payload, self.disclosures, SUPPORTED_SD_ALG_FN[self.get_sd_alg()]) + def get_issuer_jwt(self) -> str: + return self.issuer_jwt.jwt + + def get_holder_key_binding(self) -> str: + return self.holder_kb.jwt + def get_sd_alg(self) -> str: return self.issuer_jwt.payload.get("_sd_alg", DEFAULT_SD_ALG) @@ -104,10 +113,10 @@ def __init__(self, token: str): def _verify_challenge(hkb: UnverfiedJwt, challenge: VerifierChallenge): - if not (obt := hkb.payload.get("aud", None)) != (exp := challenge["aud"]): - raise InvalidKeyBinding(f"challene audience {exp} due not match obtained audience {obt}") - if not (obt := hkb.payload.get("nonce", None)) != (exp := challenge["nonce"]): - raise InvalidKeyBinding(f"challene nonce {exp} due not match obtained nonce {obt}") + if (obt := hkb.payload.get("aud", None)) != (exp := challenge["aud"]): + raise InvalidKeyBinding(f"challenge audience {exp} does not match obtained audience {obt}") + if (obt := hkb.payload.get("nonce", None)) != (exp := challenge["nonce"]): + raise InvalidKeyBinding(f"challenge nonce {exp} does not match obtained nonce {obt}") def _verify_sd_hash(token_without_hkb: str, sd_hash_alg: str, expected_digest: str): @@ -152,16 +161,16 @@ def _is_element_leaf(element: Any) -> bool: and type(element[SD_LIST_PREFIX]) is str) -def _unpack_json_array(claims: list, decoded_disclosures_by_digest: dict[str, Any], sd_alg: Callable[[str], str], proceessed_digests: list[str]) -> list: +def _unpack_json_array(claims: list, decoded_disclosures_by_digest: dict[str, Any], sd_alg: Callable[[str], str], processed_digests: list[str]) -> list: result = [] for element in claims: if _is_element_leaf(element): digest: str = element[SD_LIST_PREFIX] if digest in decoded_disclosures_by_digest: _, value = decoded_disclosures_by_digest[digest] - result.append(_unpack_claims(value, decoded_disclosures_by_digest, sd_alg, proceessed_digests)) + result.append(_unpack_claims(value, decoded_disclosures_by_digest, sd_alg, processed_digests)) else: - result.append(_unpack_claims(element, decoded_disclosures_by_digest, sd_alg, proceessed_digests)) + result.append(_unpack_claims(element, decoded_disclosures_by_digest, sd_alg, processed_digests)) return result @@ -172,7 +181,7 @@ def _unpack_json_dict(claims: dict, decoded_disclosures_by_digest: dict[str, Any filtered_unpacked_claims = {} for k, v in claims.items(): if k != SD_DIGESTS_KEY and k != DIGEST_ALG_KEY: - filtered_unpacked_claims[k] = _unpack_claims(v, decoded_disclosures_by_digest, sd_alg) + filtered_unpacked_claims[k] = _unpack_claims(v, decoded_disclosures_by_digest, sd_alg, proceessed_digests) for disclosed_digests in claims.get(SD_DIGESTS_KEY, []): if disclosed_digests in proceessed_digests: diff --git a/pyeudiw/tests/sd_jwt/test_sdjwt.py b/pyeudiw/tests/sd_jwt/test_sdjwt.py new file mode 100644 index 00000000..dea04e09 --- /dev/null +++ b/pyeudiw/tests/sd_jwt/test_sdjwt.py @@ -0,0 +1,223 @@ +import builtins +from dataclasses import dataclass + +from pyeudiw.jwk import JWK +from pyeudiw.sd_jwt.schema import VerifierChallenge +from pyeudiw.sd_jwt.sd_jwt import SdJwt + +# DEVELOPER NOTE: test data is collected from https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-12.html +# Test data might eventually be outdated if the reference specs changes or is updated. +# For the latest version, see https://github.com/oauth-wg/oauth-selective-disclosure-jwt + +ISSUER_JWK = { + "kty": "EC", + "d": "Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g", + "crv": "P-256", + "x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ", + "y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8" +} + +PRESENTATION_WITHOUT_KB = \ + "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBb" \ + "IkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZ" \ + "akg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBL" \ + "dVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1" \ + "SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tB" \ + "TmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2" \ + "Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFr" \ + "b2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpn" \ + "bGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUu" \ + "Y29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjog" \ + "InVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15" \ + "VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1" \ + "ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjog" \ + "InNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0y" \ + "NTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VH" \ + "ZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlG" \ + "MkhaUSJ9fX0.ZfSxIFLHf7f84WIMqt7Fzme8-586WutjFnXH4TO5XuWG_peQ4hPsqDpi" \ + "MBClkh2aUJdl83bwyyOriqvdFra-bg~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgI" \ + "mdpdmVuX25hbWUiLCAiSm9obiJd~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZh" \ + "bWlseV9uYW1lIiwgIkRvZSJd~WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWl" \ + "sIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ~WyJlSThaV205UW5LUHBOUGVOZW5IZGhR" \ + "IiwgInBob25lX251bWJlciIsICIrMS0yMDItNTU1LTAxMDEiXQ~WyJRZ19PNjR6cUF4Z" \ + "TQxMmExMDhpcm9BIiwgInBob25lX251bWJlcl92ZXJpZmllZCIsIHRydWVd~WyJBSngt" \ + "MDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjog" \ + "IjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFu" \ + "eXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZR" \ + "IiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0~WyJHMDJOU3JRZmpGWFE3SW8wOXN5" \ + "YWpBIiwgInVwZGF0ZWRfYXQiLCAxNTcwMDAwMDAwXQ~WyJsa2x4RjVqTVlsR1RQVW92T" \ + "U5JdkNBIiwgIlVTIl0~WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0~" + +PRESENTATION_WITH_KB = \ + "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBb" \ + "IkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZ" \ + "akg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBL" \ + "dVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1" \ + "SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tB" \ + "TmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2" \ + "Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFr" \ + "b2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpn" \ + "bGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUu" \ + "Y29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjog" \ + "InVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15" \ + "VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1" \ + "ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjog" \ + "InNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0y" \ + "NTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VH" \ + "ZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlG" \ + "MkhaUSJ9fX0.ZfSxIFLHf7f84WIMqt7Fzme8-586WutjFnXH4TO5XuWG_peQ4hPsqDpi" \ + "MBClkh2aUJdl83bwyyOriqvdFra-bg~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgI" \ + "mZhbWlseV9uYW1lIiwgIkRvZSJd~WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFk" \ + "ZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5" \ + "IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMi" \ + "fV0~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd" \ + "~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0~eyJhbGciOiAiRVMyNTYiLCA" \ + "idHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodH" \ + "RwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwgImlhdCI6IDE3MjUzNzQ0MTMsICJzZF" \ + "9oYXNoIjogIkF5T0p2TFlQVk1sS2REbGZacnpVeTFrX2ltQ0tfTFZKMzI2Yl94QmtFM0" \ + "0ifQ.B2o5kubh-Dzcd-2v_mWxUMPNM5WSAJqMQTDsGQUXkZXzsN1U5Ou5mr-7iJsCGcx" \ + "6_uU39u-2HKB0xLvYd9BMcQ" + + +ISSUER_JWT = \ + "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBb" \ + "IkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZ" \ + "akg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBL" \ + "dVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1" \ + "SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tB" \ + "TmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2" \ + "Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFr" \ + "b2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpn" \ + "bGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUu" \ + "Y29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjog" \ + "InVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15" \ + "VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1" \ + "ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjog" \ + "InNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0y" \ + "NTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VH" \ + "ZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlG" \ + "MkhaUSJ9fX0.ZfSxIFLHf7f84WIMqt7Fzme8-586WutjFnXH4TO5XuWG_peQ4hPsqDpi" \ + "MBClkh2aUJdl83bwyyOriqvdFra-bg" + +DISCLOSURES = [ + "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd", + "WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRy" + "ZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9u" + "IjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0", + "WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd", + "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0", +] +HOLDER_KB_JWT = \ + "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY" \ + "3ODkwIiwgImF1ZCI6ICJodHRwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwgImlhdCI" \ + "6IDE3MjUzNzQ0MTMsICJzZF9oYXNoIjogIkF5T0p2TFlQVk1sS2REbGZacnpVeTFrX2l" \ + "tQ0tfTFZKMzI2Yl94QmtFM00ifQ.B2o5kubh-Dzcd-2v_mWxUMPNM5WSAJqMQTDsGQUX" \ + "kZXzsN1U5Ou5mr-7iJsCGcx6_uU39u-2HKB0xLvYd9BMcQ" + +AUD = "https://verifier.example.org" +NONCE = "1234567890" + +DISCLOSED_CLAIMS = { + "given_name": "John", + "family_name": "Doe", + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anystate", + "country": "US" + }, + "nationalities": [ + "US" + ] +} + + +def test_sdkwt_parts(): + sdjwt = SdJwt(PRESENTATION_WITH_KB) + assert ISSUER_JWT == sdjwt.get_issuer_jwt() + assert DISCLOSURES == sdjwt.get_disclosures() + assert HOLDER_KB_JWT == sdjwt.get_holder_key_binding() + + +def test_sdjwt_hash_hey_binding(): + sdjwt = SdJwt(PRESENTATION_WITHOUT_KB) + assert not sdjwt.has_key_binding() + + sdjwt = SdJwt(PRESENTATION_WITH_KB) + assert sdjwt.has_key_binding() + + +def test_sd_jwt_verify_issuer_jwt(): + sdjwt = SdJwt(PRESENTATION_WITH_KB) + sdjwt.verify_issuer_jwt(JWK(ISSUER_JWK)) + + +def test_sd_jwt_verify_holder_kb_signature(): + sdjwt = SdJwt(PRESENTATION_WITH_KB) + sdjwt.verify_holder_kb_signature() + + +def test_sd_jwt_verify_holder_kb(): + sdjwt = SdJwt(PRESENTATION_WITH_KB) + + @dataclass + class TestCase: + challenge: VerifierChallenge + expected_result: bool + explanation: str + + test_cases: list[TestCase] = [ + TestCase( + challenge={"aud": "https://bad-aud.example", "nonce": "000000"}, + expected_result=False, + explanation="bad challenge (both aud and nonce are wrong)" + ), + TestCase( + challenge={"aud": AUD, "nonce": "000000"}, + expected_result=False, + explanation="bad challenge (nonce is wrong)" + ), + TestCase( + challenge={"aud": "https://bad-aud.example", "nonce": NONCE}, + expected_result=False, + explanation="bad challenge (aud is wrong)" + ), + TestCase( + challenge={"aud": AUD, "nonce": NONCE}, + expected_result=True, + explanation="valid challenge (challenge aud and nonce are correct)" + ) + ] + + for i, case in enumerate(test_cases): + try: + # bad challenge: should fail + sdjwt.verify_holder_kb(case.challenge) + if case.expected_result is False: + assert False, f"failed test {i} on holder key binding: test case: {case.explanation}: should have launched a verification exception" + else: + assert True + except Exception as e: + if case.expected_result is False: + assert True + else: + assert False, f"failed test {i}: test case: {case.explanation}; launched an unxpected verification exception: {e}" + + +def test_sd_jwt_get_disclosed_claims(): + sdjwt = SdJwt(PRESENTATION_WITH_KB) + obtained_claims = sdjwt.get_disclosed_claims() + breakpoint() + for claim in DISCLOSED_CLAIMS: + assert claim in obtained_claims, f"failed to disclose claim {claim}" + exp_claim_value = DISCLOSED_CLAIMS[claim] + obt_claim_value = obtained_claims[claim] + # NOTE: this comparison algorithm for disclosures in general does not work; + # the ideal would be a recursive approach is required, but it is ok for this test + match type(exp_claim_value): + case builtins.list: + assert all(v in obt_claim_value for v in exp_claim_value), f"failed proper disclosure of claim {claim}" + case builtins.dict: + assert exp_claim_value.items() <= obt_claim_value.items() + case _: + assert obt_claim_value == exp_claim_value, f"failed proper disclosure of claim {claim}" From dab2384faf84e09a9b96269a4cc43b30c97cef26 Mon Sep 17 00:00:00 2001 From: elisanp Date: Wed, 2 Oct 2024 12:05:57 +0200 Subject: [PATCH 10/13] wip: cleanup --- pyeudiw/openid4vp/vp_sd_jwt_vc.py | 2 +- pyeudiw/sd_jwt/sd_jwt.py | 20 ++++++++++---------- pyeudiw/tests/sd_jwt/test_sdjwt.py | 17 ++++++++--------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/pyeudiw/openid4vp/vp_sd_jwt_vc.py b/pyeudiw/openid4vp/vp_sd_jwt_vc.py index f9856dbd..2d1052c5 100644 --- a/pyeudiw/openid4vp/vp_sd_jwt_vc.py +++ b/pyeudiw/openid4vp/vp_sd_jwt_vc.py @@ -36,4 +36,4 @@ def is_expired(self) -> bool: return is_jwt_expired(self.sdjwt.issuer_jwt) def verify_signature(self, public_key: JWK) -> None: - return self.sdjwt.verify_issuer_jwt(public_key) + return self.sdjwt.verify_issuer_jwt_signature(public_key) diff --git a/pyeudiw/sd_jwt/sd_jwt.py b/pyeudiw/sd_jwt/sd_jwt.py index e41cffac..dcaa2fea 100644 --- a/pyeudiw/sd_jwt/sd_jwt.py +++ b/pyeudiw/sd_jwt/sd_jwt.py @@ -30,7 +30,7 @@ class SdJwt: """ - SdJwt is an utility class to easly [arse and verify sd jwt. + SdJwt is an utility class to easily parse and verify sd jwt. All class attributes are intended to be read only """ @@ -57,10 +57,10 @@ def _post_init_precomputed_values(self): def get_confirmation_key(self) -> dict: cnf: dict = self.issuer_jwt.payload.get("cnf", {}).get("jwk", {}) if not cnf: - raise ValueError("missing confermation (cnf) key from issuer payload claims") + raise ValueError("missing confirmation (cnf) key from issuer payload claims") return cnf - def get_disclosures(self) -> list[str]: + def get_encoded_disclosures(self) -> list[str]: return self.disclosures def get_disclosed_claims(self) -> dict: @@ -69,7 +69,7 @@ def get_disclosed_claims(self) -> dict: def get_issuer_jwt(self) -> str: return self.issuer_jwt.jwt - def get_holder_key_binding(self) -> str: + def get_holder_key_binding_jwt(self) -> str: return self.holder_kb.jwt def get_sd_alg(self) -> str: @@ -78,24 +78,24 @@ def get_sd_alg(self) -> str: def has_key_binding(self) -> bool: return self.holder_kb is not None - def verify_issuer_jwt(self, key: JWK) -> None: + def verify_issuer_jwt_signature(self, key: JWK) -> None: verify_jws_with_key(self.issuer_jwt.jwt, key) - def verify_holder_kb(self, challenge: VerifierChallenge) -> None: + def verify_holder_kb_jwt(self, challenge: VerifierChallenge) -> None: """ Checks validity of holder key binding. - This procedurre always passes when no key binding is used + This procedure always passes when no key binding is used - :raises UnsupportedSdAlg: if verification fails due to an unkown _sd_alf + :raises UnsupportedSdAlg: if verification fails due to an unkown _sd_alg :raises InvalidKeyBinding: if the verification fails for a known reason """ if not self.has_key_binding(): return _verify_key_binding(self.token_without_kb, self.get_sd_alg(), self.holder_kb, challenge) - self.verify_holder_kb_signature() + self.verify_holder_kb_jwt_signature() - def verify_holder_kb_signature(self) -> None: + def verify_holder_kb_jwt_signature(self) -> None: if not self.has_key_binding(): return cnf = self.get_confirmation_key() diff --git a/pyeudiw/tests/sd_jwt/test_sdjwt.py b/pyeudiw/tests/sd_jwt/test_sdjwt.py index dea04e09..b490a008 100644 --- a/pyeudiw/tests/sd_jwt/test_sdjwt.py +++ b/pyeudiw/tests/sd_jwt/test_sdjwt.py @@ -101,9 +101,9 @@ DISCLOSURES = [ "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd", - "WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRy" - "ZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9u" - "IjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0", + "WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRy" + + "ZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9u" + + "IjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0", "WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd", "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0", ] @@ -135,8 +135,8 @@ def test_sdkwt_parts(): sdjwt = SdJwt(PRESENTATION_WITH_KB) assert ISSUER_JWT == sdjwt.get_issuer_jwt() - assert DISCLOSURES == sdjwt.get_disclosures() - assert HOLDER_KB_JWT == sdjwt.get_holder_key_binding() + assert DISCLOSURES == sdjwt.get_encoded_disclosures() + assert HOLDER_KB_JWT == sdjwt.get_holder_key_binding_jwt() def test_sdjwt_hash_hey_binding(): @@ -149,12 +149,12 @@ def test_sdjwt_hash_hey_binding(): def test_sd_jwt_verify_issuer_jwt(): sdjwt = SdJwt(PRESENTATION_WITH_KB) - sdjwt.verify_issuer_jwt(JWK(ISSUER_JWK)) + sdjwt.verify_issuer_jwt_signature(JWK(ISSUER_JWK)) def test_sd_jwt_verify_holder_kb_signature(): sdjwt = SdJwt(PRESENTATION_WITH_KB) - sdjwt.verify_holder_kb_signature() + sdjwt.verify_holder_kb_jwt_signature() def test_sd_jwt_verify_holder_kb(): @@ -192,7 +192,7 @@ class TestCase: for i, case in enumerate(test_cases): try: # bad challenge: should fail - sdjwt.verify_holder_kb(case.challenge) + sdjwt.verify_holder_kb_jwt(case.challenge) if case.expected_result is False: assert False, f"failed test {i} on holder key binding: test case: {case.explanation}: should have launched a verification exception" else: @@ -207,7 +207,6 @@ class TestCase: def test_sd_jwt_get_disclosed_claims(): sdjwt = SdJwt(PRESENTATION_WITH_KB) obtained_claims = sdjwt.get_disclosed_claims() - breakpoint() for claim in DISCLOSED_CLAIMS: assert claim in obtained_claims, f"failed to disclose claim {claim}" exp_claim_value = DISCLOSED_CLAIMS[claim] From 8a52a2e768a5a6356334b0a39e1aa73fba7817a1 Mon Sep 17 00:00:00 2001 From: elisanp Date: Wed, 2 Oct 2024 12:56:03 +0200 Subject: [PATCH 11/13] wip: cleanup --- pyeudiw/openid4vp/redirect.py | 0 pyeudiw/openid4vp/request.py | 0 pyeudiw/openid4vp/verifier.py | 23 -- pyeudiw/openid4vp/vp_mock.py | 15 -- pyeudiw/openid4vp/vp_sd_jwt_kb.py | 197 ------------------ pyeudiw/openid4vp/vp_wa.py | 0 pyeudiw/satosa/default/response_handler.py | 5 +- pyeudiw/sd_jwt/sd_jwt.py | 13 +- pyeudiw/tests/openid4vp/test_vp_sd_jwt_kb.py | 27 --- .../vp_void.py => tests/openid4vp/utility.py} | 0 10 files changed, 14 insertions(+), 266 deletions(-) delete mode 100644 pyeudiw/openid4vp/redirect.py delete mode 100644 pyeudiw/openid4vp/request.py delete mode 100644 pyeudiw/openid4vp/verifier.py delete mode 100644 pyeudiw/openid4vp/vp_mock.py delete mode 100644 pyeudiw/openid4vp/vp_sd_jwt_kb.py delete mode 100644 pyeudiw/openid4vp/vp_wa.py delete mode 100644 pyeudiw/tests/openid4vp/test_vp_sd_jwt_kb.py rename pyeudiw/{openid4vp/vp_void.py => tests/openid4vp/utility.py} (100%) diff --git a/pyeudiw/openid4vp/redirect.py b/pyeudiw/openid4vp/redirect.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pyeudiw/openid4vp/request.py b/pyeudiw/openid4vp/request.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pyeudiw/openid4vp/verifier.py b/pyeudiw/openid4vp/verifier.py deleted file mode 100644 index 765b9910..00000000 --- a/pyeudiw/openid4vp/verifier.py +++ /dev/null @@ -1,23 +0,0 @@ -class VpVerifier: - """VpVerifier validates and verify vp tokens - """ - - def verify(): - """verify checks the validity of a vp token based on implementation policy. - - :raises InvalidVPToken: raised when verification fails - """ - raise NotImplementedError - - def parse_digital_credential() -> dict: - """parse_digital_credential extracts digitals cretentials from aa vp - token. The credential might or might not be verified, based on - implementer policy. To ensure verification, use the method verify() - - :returns: a dictionary of credential, where the dictionary key is the - credential name and the dictionary value is the credential value - """ - raise NotImplementedError - - def check_revocation_status(): - raise NotImplementedError diff --git a/pyeudiw/openid4vp/vp_mock.py b/pyeudiw/openid4vp/vp_mock.py deleted file mode 100644 index c7eda130..00000000 --- a/pyeudiw/openid4vp/vp_mock.py +++ /dev/null @@ -1,15 +0,0 @@ -from pyeudiw.openid4vp.verifier import VpVerifier - - -class MockVpVerifier(VpVerifier): - def __init__(self, vp_token: str): - self.vp_token = vp_token - - def verify(): - pass - - def check_revocation_status(): - pass - - def parse_digital_credential() -> dict: - return {} diff --git a/pyeudiw/openid4vp/vp_sd_jwt_kb.py b/pyeudiw/openid4vp/vp_sd_jwt_kb.py deleted file mode 100644 index 3b2ddd4c..00000000 --- a/pyeudiw/openid4vp/vp_sd_jwt_kb.py +++ /dev/null @@ -1,197 +0,0 @@ -from dataclasses import dataclass -from typing import Callable, Union - -from cryptojwt.jws.exception import JWSException -from jwcrypto.common import base64url_decode, json_decode -import jwcrypto.jwk -from sd_jwt.common import SDJWTCommon -from sd_jwt.verifier import SDJWTVerifier - -from pyeudiw.jwk import JWK -from pyeudiw.jwt import JWSHelper -from pyeudiw.jwt.parse import unsafe_parse_jws -from pyeudiw.jwt.schemas.jwt import UnverfiedJwt -from pyeudiw.openid4vp.exceptions import InvalidVPKeyBinding, InvalidVPSignature, KIDNotFound, VPSchemaException -from pyeudiw.openid4vp.verifier import VpVerifier -from pyeudiw.sd_jwt.schema import KeyBindingJwtHeader, KeyBindingJwtPayload, VcSdJwtHeaderSchema, VcSdJwtPayloadSchema, is_sd_jwt_kb_format -from pyeudiw.tools.utils import iat_now - - -_CLOCK_SKEW = 0 - - -@dataclass -class VerifierChallenge: - aud: str - nonce: str - - -class VpVcSdJwtKbVerifier(VpVerifier): - - def __init__(self, sdjwtkb: str, verifier_id: str, verifier_nonce: str, jwk_by_kid: dict[str, dict]): - """ - VpVcSdJwtKbVerifier is a utility class for parsing and verifying sd-jwt. - - :param sdjwtkb: verifiable credential in sd-jwt with key binding format (raw encoded string) - :type sdjwtkb: str - :param verifier_id: the entity id of the verifier (must be matched with key binding [aud] payload claim) - :type verifier_id: str - :param verifier_nonce: the challenge nonce proposed by the verifier (must be matched with the key binding [nonce] claim) - :type verifier_nonce: str - :param jwks_by_kid: dictionary where the keys are kid(s) and the values are unmarshaled jwk - :type jwks_by_kid: dict[str, dict] - :param accepted_claims: a dictionary of accepted claims fromt th sd-jwt - claims, use an empty list [] if all claims must be accepted, otherwise a safe minimal PID is used instead - :param accepted_claims: list[str] | None - - """ - self.sdjwtkb = sdjwtkb - if not is_sd_jwt_kb_format(sdjwtkb): - raise ValueError(f"input [sdjwtkb]={sdjwtkb} is not an sd-jwt with key binding: maybe it is a regular jwt or key binding jwt is missing?") - self.verifier_id = verifier_id - self.verifier_nonce = verifier_nonce - self.jwk_by_kid = jwk_by_kid - # precomputed values - self._issuer_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") - self._encoded_disclosures: list[str] = [] - self._disclosures: list[dict] = [] - self._kb_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") - self._post_init_evaluate_precomputed_values() - - def _post_init_evaluate_precomputed_values(self): - iss_jwt, *disclosures, kb_jwt = self.sdjwtkb.split(SDJWTCommon.COMBINED_SERIALIZATION_FORMAT_SEPARATOR) - self._encoded_disclosures = disclosures - self._disclosures = [json_decode(base64url_decode(disc)) for disc in disclosures] - self._issuer_jwt = unsafe_parse_jws(iss_jwt) - self._kb_jwt = unsafe_parse_jws(kb_jwt) - - def _get_issuer_jwk(self) -> JWK: - issuer_jwk_kid: str | None = self._issuer_jwt.header.get("kid", None) - if issuer_jwk_kid is None: - raise ValueError("missing mandatory parameter [kid] in issuer jwt header") - issuer_jwk_d: dict | None = self.jwk_by_kid.get(issuer_jwk_kid, None) - if issuer_jwk_kid is None: - raise KIDNotFound(f"issuer jwt signed with kid={issuer_jwk_kid} not found in key store slice") - _jwk = JWK(issuer_jwk_d) - return _jwk - - def validate_schema(self): - try: - VcSdJwtHeaderSchema(**self._issuer_jwt.header) - VcSdJwtPayloadSchema(**self._issuer_jwt.payload) - KeyBindingJwtHeader(**self._kb_jwt.header) - KeyBindingJwtPayload(**self._kb_jwt.payload) - except Exception as e: - raise VPSchemaException(f"failed to decode sd-jwt: {e}") - - def _get_confirmation_jwk(self) -> JWK: - """Utility method that extracts the claim "cnf" from the issuer jwt. - If not such claims exists, a ValueError is returned. - """ - cnf_keys: dict | None = self._issuer_jwt.payload.get("cnf", None) - if not isinstance(cnf_keys, dict): - raise ValueError("missing of invalid claim [cnf] in issuer jwt payload") - jwk_d: dict | None = cnf_keys.get("jwk", None) - if not isinstance(jwk_d, dict): - raise ValueError("missing or invalid claim [cnf.jwk] in issuer jwt payload") - return JWK(key=jwk_d) - - def verify(self) -> None: - cnf_jwk = self._get_confirmation_jwk() - _verify_kb_jwt(self._kb_jwt, cnf_jwk, VerifierChallenge(self.verifier_id, self.verifier_nonce)) - _verify_jws_with_key(self._issuer_jwt.jwt, self._get_issuer_jwk()) - - def check_revocation_status(): - raise NotImplementedError - - def parse_digital_credential(self) -> dict: - _jwk = jwcrypto.jwk.JWK(**self._get_issuer_jwk().as_dict()) - # currently we wrap SDJWTVerifier from library https://github.com/openwallet-foundation-labs/sd-jwt-python - # but this library _also_ re-does verification, while i would like to decouple verification from credential parsing - sdjwt_verifier = SDJWTVerifier( - sd_jwt_presentation=self.sdjwtkb, - cb_get_issuer_key=wrap_jwk_to_callable_keystore(_jwk), - serialization_format="compact" - ) - payload_claims: dict = sdjwt_verifier.get_verified_payload() - return payload_claims - - def __str__(self) -> str: - return "VpVcSdJwtKb(" \ - f"sdjwt={self.sdjwtkb}, " \ - f"verifier_id={self.verifier_id}, " \ - f"verifier_nonce={self.verifier_nonce}, " \ - f"jwk_by_kid={self.jwk_by_kid}" \ - ")" - - -def _verify_jws_with_key(issuer_jwt: str, issuer_key: JWK): - try: - verifier = JWSHelper(issuer_key) - except Exception as e: - raise InvalidVPSignature(f"failed signature verification of issuer-jwt: invalid issuer key due to cause: {e}") - try: - verifier.verify(issuer_jwt) - except JWSException as e: - raise InvalidVPSignature(f"failed signature verification of issuer-jwt: {e}") - return - - -def _verify_kb_jwt(kbjwt: UnverfiedJwt, cnf_jwk: JWK, challenge: VerifierChallenge) -> None: - _verify_kb_jwt_payload_challenge(kbjwt.payload, challenge) - _verify_kb_jwt_payload_iat(kbjwt.payload) - # TODO: sd-jwt-python already does this check, however it would be space for us to have it more explicit in our code - # _verify_kb_jwt_payload_sd_hash(sdjwt) - _verify_kb_jwt_signature(kbjwt.jwt, cnf_jwk) - -# def _verify_kb_jwt_payload_sd_hash(sdjwt): -# hash_alg: str | None = sdjwt._issuer_jwt.payload.get("_sd_alg", None) -# if hash_alg is None: -# raise ValueError("missing parameter [_sd_alg] in issuer signet JWT payload") -# *parts, _ = sdjwt.sdjwtkb.split(_SD_JWT_DELIMITER) -# iss_jwt_disclosed = ''.join(parts) -# TODO: go on -# pass - - -def _verify_kb_jwt_payload_challenge(kb_jwt_payload: dict, challenge: VerifierChallenge): - aud = kb_jwt_payload.get("aud", None) - nonce = kb_jwt_payload.get("nonce", None) - if aud is None or nonce is None: - raise ValueError("missing parameter [aud] or [nonce] in kbjwt") - if aud != challenge.aud: - raise InvalidVPKeyBinding("obtained kb-jwt parameter [aud] does not match verifier audience") - if nonce != challenge.nonce: - raise InvalidVPKeyBinding("obtained kb-jwt parameter [nonce] does not match verifier nonce") - return - - -def _verify_kb_jwt_payload_iat(kb_jwt_payload: dict) -> None: - iat: int | None = kb_jwt_payload.get("iat", None) - if not isinstance(iat, int): - raise ValueError("missing or invalid parameter [iat] in kbjwt") - now = iat_now() - if iat > (now + _CLOCK_SKEW): - raise InvalidVPKeyBinding("invalid parameter [iat] in kbjwt: issuance after present time") - return - - -def _verify_kb_jwt_signature(kbjwt: str, verification_jwk: JWK) -> None: - try: - verifier = JWSHelper(verification_jwk) - except Exception as e: - raise InvalidVPKeyBinding(f"failed signature verification of kb-jwt: invalid cnf key to cause: {e}") - try: - verifier.verify(kbjwt) - except JWSException as e: - raise InvalidVPKeyBinding(f"failed signature verification of kb-jwt: {e}") - return - - -_CB_KetStore_T = Callable[[str, dict], Union[jwcrypto.jwk.JWK, jwcrypto.jwk.JWKSet]] - - -def wrap_jwk_to_callable_keystore(fixed_jwk: jwcrypto.jwk.JWK) -> _CB_KetStore_T: - """wrap a jwk to a trivial keystore where the input `fixed_jwk` is always returned - """ - return lambda iss, header: fixed_jwk diff --git a/pyeudiw/openid4vp/vp_wa.py b/pyeudiw/openid4vp/vp_wa.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pyeudiw/satosa/default/response_handler.py b/pyeudiw/satosa/default/response_handler.py index 278ad348..c04c3868 100644 --- a/pyeudiw/satosa/default/response_handler.py +++ b/pyeudiw/satosa/default/response_handler.py @@ -137,7 +137,7 @@ def _is_same_device_flow(request_session: dict, context: Context) -> bool: return initiating_session_id == current_session_id def response_endpoint(self, context: Context, *args: tuple) -> Redirect | JsonResponse: - self._log_function_debug("request_endpoint", context, "args", args) + self._log_function_debug("response_endpoint", context, "args", args) request_dict = {} try: @@ -308,5 +308,6 @@ def _find_vp_token_key(token_parser: VpTokenParser, key_source: TrustEvaluator) if len(pub_jwks) != 1: raise Exception(f"no unique valid trusted key with kid={kid} for issuer {issuer}") return JWK(pub_jwks[0]) - else: + if isinstance(verification_key, dict): raise NotImplementedError("TODO: matching of public key (ex. from x5c) with keys from trust source") + raise Exception(f"invalid state: key with type {type(verification_key)}") diff --git a/pyeudiw/sd_jwt/sd_jwt.py b/pyeudiw/sd_jwt/sd_jwt.py index dcaa2fea..f757082a 100644 --- a/pyeudiw/sd_jwt/sd_jwt.py +++ b/pyeudiw/sd_jwt/sd_jwt.py @@ -8,10 +8,10 @@ from pyeudiw.jwt.parse import unsafe_parse_jws from pyeudiw.jwt.utils import base64_urldecode, base64_urlencode from pyeudiw.jwt.verification import verify_jws_with_key -from pyeudiw.openid4vp.vp_sd_jwt_kb import VerifierChallenge from pyeudiw.sd_jwt.exceptions import InvalidKeyBinding, UnsupportedSdAlg -from pyeudiw.sd_jwt.schema import is_sd_jwt_format, is_sd_jwt_kb_format +from pyeudiw.sd_jwt.schema import is_sd_jwt_format, is_sd_jwt_kb_format, VerifierChallenge from pyeudiw.jwt.schemas.jwt import UnverfiedJwt +from pyeudiw.tools.utils import iat_now _JsonTypes = dict | list | str | int | float | bool | None @@ -126,10 +126,19 @@ def _verify_sd_hash(token_without_hkb: str, sd_hash_alg: str, expected_digest: s if expected_digest != (obt_digest := hash_fn(token_without_hkb)): raise InvalidKeyBinding(f"sd-jwt digest {obt_digest} does not match expected digest {expected_digest}") +def _verify_iat(payload: dict) -> None: + iat: int | None = payload.get("iat", None) + if not isinstance(iat, int): + raise ValueError("missing or invalid parameter [iat] in kbjwt") + now = iat_now() + if iat > now: + raise InvalidKeyBinding("invalid parameter [iat] in kbjwt: issuance after present time") + return def _verify_key_binding(token_without_hkb: str, sd_hash_alg: str, hkb: UnverfiedJwt, challenge: VerifierChallenge): _verify_challenge(hkb, challenge) _verify_sd_hash(token_without_hkb, sd_hash_alg, hkb.payload.get("sd_hash", "")) + _verify_iat(hkb.payload) def _disclosures_to_hash_mappings(disclosures: list[str], sd_alg: Callable[[str], str]) -> tuple[dict[str, str], dict[str, Any]]: diff --git a/pyeudiw/tests/openid4vp/test_vp_sd_jwt_kb.py b/pyeudiw/tests/openid4vp/test_vp_sd_jwt_kb.py deleted file mode 100644 index 88793763..00000000 --- a/pyeudiw/tests/openid4vp/test_vp_sd_jwt_kb.py +++ /dev/null @@ -1,27 +0,0 @@ -from pyeudiw.openid4vp.exceptions import VPSchemaException -from pyeudiw.openid4vp.vp_sd_jwt_kb import VpVcSdJwtKbVerifier - - -def test_VpVcSdJwtKbVerifier(): - token = "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogInZjK3NkLWp3dCIsICJraWQiOiAiZG9jLXNpZ25lci0wNS0yNS0yMDIyIn0.eyJfc2QiOiBbIjA5dktySk1PbHlUV00wc2pwdV9wZE9CVkJRMk0xeTNLaHBINTE1blhrcFkiLCAiMnJzakdiYUMwa3k4bVQwcEpyUGlvV1RxMF9kYXcxc1g3NnBvVWxnQ3diSSIsICJFa084ZGhXMGRIRUpidlVIbEVfVkNldUM5dVJFTE9pZUxaaGg3WGJVVHRBIiwgIklsRHpJS2VpWmREd3BxcEs2WmZieXBoRnZ6NUZnbldhLXNONndxUVhDaXciLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiamRyVEU4WWNiWTRFaWZ1Z2loaUFlX0JQZWt4SlFaSUNlaVVRd1k5UXF4SSIsICJqc3U5eVZ1bHdRUWxoRmxNXzNKbHpNYVNGemdsaFFHMERwZmF5UXdMVUs0Il0sICJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9pc3N1ZXIiLCAiaWF0IjogMTY4MzAwMDAwMCwgImV4cCI6IDE4ODMwMDAwMDAsICJ2Y3QiOiAiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLmNvbS9pZGVudGl0eV9jcmVkZW50aWFsIiwgIl9zZF9hbGciOiAic2hhLTI1NiIsICJjbmYiOiB7Imp3ayI6IHsia3R5IjogIkVDIiwgImNydiI6ICJQLTI1NiIsICJ4IjogIlRDQUVSMTladnUzT0hGNGo0VzR2ZlNWb0hJUDFJTGlsRGxzN3ZDZUdlbWMiLCAieSI6ICJaeGppV1diWk1RR0hWV0tWUTRoYlNJaXJzVmZ1ZWNDRTZ0NGpUOUYySFpRIn19fQ.sMpGS2JtqTtflUN-ToEm2VueqHhVCpUtOXk0SV5Tjj7FulFGae2fIaULLDjdKa46T-wtI9nKMoSNqe_38uwBhg~WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodHRwczovL2V4YW1wbGUuY29tL3ZlcmlmaWVyIiwgImlhdCI6IDE3MjA0NTQyOTUsICJzZF9oYXNoIjogIkEyM3lUaG5uN0FidlVlNkE5N1YtTHJsdGFRUTRaMkdIRjJlUXBMUkRCVncifQ.u0CjLD2kenwkwA4vttK7PFhjtEEV5r4dYMR7TW1VAC35xIc1dMkEtvdTRwLQBO1Tu9VcbkvxT-G9ooTTVZMD2g" - aud = "https://example.com/verifier" - nonce = "1234567890" - jwk_d = { - "doc-signer-05-25-2022": { - "kid": "doc-signer-05-25-2022", - "kty": "EC", - "crv": "P-256", - "x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ", - "y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8" - } - } - verifier = VpVcSdJwtKbVerifier(token, aud, nonce, jwk_d) - try: - verifier.validate_schema() - except VPSchemaException: - # TODO: example that is actually aligned with italian specs - pass - verifier.verify() - expected_credentials = {"address": {"street_address": "123 Main St", "locality": "Anytown", "region": "Anystate", "country": "US"}} - credentials = verifier.parse_digital_credential() - assert expected_credentials.items() <= credentials.items(), f"failed to parse credentials: expected {expected_credentials}, obtained {credentials}" diff --git a/pyeudiw/openid4vp/vp_void.py b/pyeudiw/tests/openid4vp/utility.py similarity index 100% rename from pyeudiw/openid4vp/vp_void.py rename to pyeudiw/tests/openid4vp/utility.py From 802bbb5fcf6105317dbfcffbd95fa496bf535023 Mon Sep 17 00:00:00 2001 From: Zicchio Date: Wed, 2 Oct 2024 14:56:44 +0200 Subject: [PATCH 12/13] wip: fixes --- example/satosa/pyeudiw_backend.yaml | 21 +++++++++--------- pyeudiw/jwt/parse.py | 22 ++++++++++++++++--- pyeudiw/jwt/schemas/jwt.py | 9 -------- pyeudiw/satosa/default/openid4vp_backend.py | 13 ----------- pyeudiw/sd_jwt/sd_jwt.py | 17 +++++++------- pyeudiw/tests/trust/default/settings.py | 10 ++++++++- .../tests/trust/default/test_direct_trust.py | 9 +------- pyeudiw/trust/default/federation.py | 14 ++++++------ pyeudiw/trust/default/x509.py | 4 ++-- 9 files changed, 57 insertions(+), 62 deletions(-) diff --git a/example/satosa/pyeudiw_backend.yaml b/example/satosa/pyeudiw_backend.yaml index b9c37621..051bf88d 100644 --- a/example/satosa/pyeudiw_backend.yaml +++ b/example/satosa/pyeudiw_backend.yaml @@ -81,6 +81,16 @@ config: timeout: 6 trust: + direct_trust: + module: pyeudiw.trust.default.direct_trust + class: DirectTrustSdJwtVc + config: + jwk_endpoint: /.well-known/jwt-vc-issuer + httpc_params: + connection: + ssl: true + session: + timeout: 6 federation: module: pyeudiw.trust.default.federation class: FederationTrustModel @@ -106,7 +116,6 @@ config: n: utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw p: 2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0 q: 2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM - x509: module: pyeudiw.trust.default.x509 class: X509TrustModel @@ -115,16 +124,6 @@ config: - "todo" trust_anchors_cn: # we might mix CN and SAN together - http://127.0.0.1:8000 - direct_trust: - module: pyeudiw.trust.default.direct_trust - class: DirectTrustSdJwtVc - config: - jwk_endpoint: /.well-known/jwt-vc-issuer - httpc_params: - connection: - ssl: true - session: - timeout: 6 # FORMER IMPLEMENTATION OF TRUST CONFIGURATION # federation: # metadata_type: "wallet_relying_party" diff --git a/pyeudiw/jwt/parse.py b/pyeudiw/jwt/parse.py index d211f758..debe5d0a 100644 --- a/pyeudiw/jwt/parse.py +++ b/pyeudiw/jwt/parse.py @@ -1,18 +1,34 @@ +from dataclasses import dataclass from jwcrypto.common import base64url_decode, json_decode from pyeudiw.federation.trust_chain.parse import get_public_key_from_trust_chain from pyeudiw.jwk import JWK -from pyeudiw.jwt.schemas.jwt import UnverfiedJwt from pyeudiw.jwt.utils import is_jwt_format from pyeudiw.x509.verify import get_public_key_from_x509_chain KeyIdentifier_T = str +@dataclass(frozen=True) +class DecodedJwt: + """ + Schema class for a decoded jwt. + This class is not meant to be instantiated directly. Use instead + the static metho parse(str) -> UnverfiedJwt + """ + jwt: str + header: dict + payload: dict + signature: str + + def parse(jws: str) -> 'DecodedJwt': + return unsafe_parse_jws(jws) + + def _unsafe_decode_part(part: str) -> dict: return json_decode(base64url_decode(part)) -def unsafe_parse_jws(token: str) -> UnverfiedJwt: +def unsafe_parse_jws(token: str) -> DecodedJwt: """Parse a token into it's component. Correctness of this function is not guaranteed when the token is in a derived format, such as sd-jwt and jwe. @@ -27,7 +43,7 @@ def unsafe_parse_jws(token: str) -> UnverfiedJwt: payload = _unsafe_decode_part(b64payload) except Exception as e: raise ValueError(f"unable to decode JWS part: {e}") - return UnverfiedJwt(token, head, payload, signature=signature) + return DecodedJwt(token, head, payload, signature=signature) def extract_key_identifier(token_header: dict) -> JWK | KeyIdentifier_T: diff --git a/pyeudiw/jwt/schemas/jwt.py b/pyeudiw/jwt/schemas/jwt.py index 75af3b70..6926026c 100644 --- a/pyeudiw/jwt/schemas/jwt.py +++ b/pyeudiw/jwt/schemas/jwt.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from pydantic import BaseModel, Field from pyeudiw.federation.schemas.wallet_relying_party import SigningAlgValuesSupported, EncryptionAlgValuesSupported, EncryptionEncValuesSupported @@ -11,11 +10,3 @@ class JWTConfig(BaseModel): enc_alg_supported: list[EncryptionAlgValuesSupported] enc_enc_supported: list[EncryptionEncValuesSupported] sig_alg_supported: list[SigningAlgValuesSupported] - - -@dataclass(frozen=True) -class UnverfiedJwt: - jwt: str - header: dict - payload: dict - signature: str diff --git a/pyeudiw/satosa/default/openid4vp_backend.py b/pyeudiw/satosa/default/openid4vp_backend.py index 1d58fc03..fc90c6d1 100644 --- a/pyeudiw/satosa/default/openid4vp_backend.py +++ b/pyeudiw/satosa/default/openid4vp_backend.py @@ -18,7 +18,6 @@ from pyeudiw.tools.mobile import is_smartphone from pyeudiw.tools.utils import iat_now from pyeudiw.trust.dynamic import CombinedTrustEvaluator, dynamic_trust_evaluators_loader -from pyeudiw.trust.interface import IssuerTrustEvaluator from ..interfaces.openid4vp_backend import OpenID4VPBackendInterface @@ -98,18 +97,6 @@ def __init__( trust_configuration = self.config.get("trust", {}) self.trust_evaluator = CombinedTrustEvaluator(dynamic_trust_evaluators_loader(trust_configuration)) - def _trust_model_factory(self) -> IssuerTrustEvaluator: - """Questa funzione eroga uno (o più?) Issuer Trust Model basandosi sulle configurazioni dell'applicativo. - """ - # TODO: leggi le configurationi trust e implementa una funzione di dynamic backend load. - # È aperto il problema su come fare dependancy injection verso queste classi: una idea - # semplice è standardizzare il costruttore. Ho l'impressione che sto abusando du un factory - # pattern senza avere un idoneo framework di dependency injection (tipo Spring Core, per dire) - # e questo potrebbe compromettere la leggibilità del codice. - trust_config: dict = self.config.get("trust", {}) - trust_evaluator = IssuerTrustEvaluator(trust_config) - return trust_evaluator - def register_endpoints(self) -> list[tuple[str, Callable[[Context], Response]]]: """ Creates a list of all the endpoints this backend module needs to listen to. In this case diff --git a/pyeudiw/sd_jwt/sd_jwt.py b/pyeudiw/sd_jwt/sd_jwt.py index f757082a..cf8965a8 100644 --- a/pyeudiw/sd_jwt/sd_jwt.py +++ b/pyeudiw/sd_jwt/sd_jwt.py @@ -5,12 +5,11 @@ from sd_jwt.common import SDJWTCommon from pyeudiw.jwk import JWK -from pyeudiw.jwt.parse import unsafe_parse_jws from pyeudiw.jwt.utils import base64_urldecode, base64_urlencode from pyeudiw.jwt.verification import verify_jws_with_key from pyeudiw.sd_jwt.exceptions import InvalidKeyBinding, UnsupportedSdAlg from pyeudiw.sd_jwt.schema import is_sd_jwt_format, is_sd_jwt_kb_format, VerifierChallenge -from pyeudiw.jwt.schemas.jwt import UnverfiedJwt +from pyeudiw.jwt.parse import DecodedJwt from pyeudiw.tools.utils import iat_now @@ -40,18 +39,18 @@ def __init__(self, token: str): self.token = token # precomputed values self.token_without_kb: str = "" - self.issuer_jwt: UnverfiedJwt = UnverfiedJwt("", "", "", "") + self.issuer_jwt: DecodedJwt = DecodedJwt("", "", "", "") self.disclosures: list[str] = [] - self.holder_kb: UnverfiedJwt | None = None + self.holder_kb: DecodedJwt | None = None self._post_init_precomputed_values() def _post_init_precomputed_values(self): iss_jwt, *disclosures, kb_jwt = self.token.split(FORMAT_SEPARATOR) self.token_without_kb = iss_jwt + FORMAT_SEPARATOR + ''.join(disc + FORMAT_SEPARATOR for disc in disclosures) - self.issuer_jwt = unsafe_parse_jws(iss_jwt) + self.issuer_jwt = DecodedJwt.parse(iss_jwt) self.disclosures = disclosures if kb_jwt: - self.holder_kb = unsafe_parse_jws(kb_jwt) + self.holder_kb = DecodedJwt.parse(kb_jwt) # TODO: schema validations(?) def get_confirmation_key(self) -> dict: @@ -112,7 +111,7 @@ def __init__(self, token: str): raise ValueError("missing key binding jwt") -def _verify_challenge(hkb: UnverfiedJwt, challenge: VerifierChallenge): +def _verify_challenge(hkb: DecodedJwt, challenge: VerifierChallenge): if (obt := hkb.payload.get("aud", None)) != (exp := challenge["aud"]): raise InvalidKeyBinding(f"challenge audience {exp} does not match obtained audience {obt}") if (obt := hkb.payload.get("nonce", None)) != (exp := challenge["nonce"]): @@ -126,6 +125,7 @@ def _verify_sd_hash(token_without_hkb: str, sd_hash_alg: str, expected_digest: s if expected_digest != (obt_digest := hash_fn(token_without_hkb)): raise InvalidKeyBinding(f"sd-jwt digest {obt_digest} does not match expected digest {expected_digest}") + def _verify_iat(payload: dict) -> None: iat: int | None = payload.get("iat", None) if not isinstance(iat, int): @@ -135,7 +135,8 @@ def _verify_iat(payload: dict) -> None: raise InvalidKeyBinding("invalid parameter [iat] in kbjwt: issuance after present time") return -def _verify_key_binding(token_without_hkb: str, sd_hash_alg: str, hkb: UnverfiedJwt, challenge: VerifierChallenge): + +def _verify_key_binding(token_without_hkb: str, sd_hash_alg: str, hkb: DecodedJwt, challenge: VerifierChallenge): _verify_challenge(hkb, challenge) _verify_sd_hash(token_without_hkb, sd_hash_alg, hkb.payload.get("sd_hash", "")) _verify_iat(hkb.payload) diff --git a/pyeudiw/tests/trust/default/settings.py b/pyeudiw/tests/trust/default/settings.py index c6168c26..1d325955 100644 --- a/pyeudiw/tests/trust/default/settings.py +++ b/pyeudiw/tests/trust/default/settings.py @@ -1,3 +1,7 @@ +import json +import requests + + issuer = "https://credential-issuer.example/vct/" issuer_jwk = { "kty": "EC", @@ -13,4 +17,8 @@ issuer_jwk ] } -} \ No newline at end of file +} +jwt_vc_issuer_endpoint_response = requests.Response() +jwt_vc_issuer_endpoint_response.status_code = 200 +jwt_vc_issuer_endpoint_response.headers.update({"Content-Type": "application/json"}) +jwt_vc_issuer_endpoint_response._content = json.dumps(issuer_vct_md).encode('utf-8') diff --git a/pyeudiw/tests/trust/default/test_direct_trust.py b/pyeudiw/tests/trust/default/test_direct_trust.py index e702358a..babdbd90 100644 --- a/pyeudiw/tests/trust/default/test_direct_trust.py +++ b/pyeudiw/tests/trust/default/test_direct_trust.py @@ -1,20 +1,13 @@ -import json -import requests import unittest.mock from pyeudiw.trust.default import DEFAULT_DIRECT_TRUST_PARAMS from pyeudiw.trust.default.direct_trust import DirectTrustSdJwtVc -from pyeudiw.tests.trust.default.settings import issuer, issuer_vct_md +from pyeudiw.tests.trust.default.settings import issuer, jwt_vc_issuer_endpoint_response from pyeudiw.tests.trust.default.settings import issuer_jwk as expected_jwk def test_direct_trust_jwk(): - jwt_vc_issuer_endpoint_response = requests.Response() - jwt_vc_issuer_endpoint_response.status_code = 200 - jwt_vc_issuer_endpoint_response.headers.update({"Content-Type": "application/json"}) - jwt_vc_issuer_endpoint_response._content = json.dumps(issuer_vct_md).encode('utf-8') - mocked_issuer_jwt_vc_issuer_endpoint = unittest.mock.patch("pyeudiw.vci.jwks_provider.get_http_url", return_value=[jwt_vc_issuer_endpoint_response]) mocked_issuer_jwt_vc_issuer_endpoint.start() diff --git a/pyeudiw/trust/default/federation.py b/pyeudiw/trust/default/federation.py index 6ec27911..0d1838cd 100644 --- a/pyeudiw/trust/default/federation.py +++ b/pyeudiw/trust/default/federation.py @@ -21,12 +21,12 @@ from pyeudiw.federation.policy import TrustChainPolicy from pyeudiw.jwt.utils import decode_jwt_payload -from pyeudiw.trust.interface import IssuerTrustEvaluator +from pyeudiw.trust.interface import TrustEvaluator logger = logging.getLogger(__name__) -class FederationTrustModel(IssuerTrustEvaluator): +class FederationTrustModel(TrustEvaluator): _ISSUER_METADATA_TYPE = "openid_credential_issuer" def __init__(self, **kwargs): @@ -89,11 +89,11 @@ def get_verified_key(self, issuer: str, token_header: dict) -> JWK: # TODO: sistema da qui in giù # --------------------------- - def __getattribute__(self, name: str) -> Any: - if hasattr(self, name): - return getattr(self, name) - logger.critical("se vedi questo messaggio: sei perduto") - return None + # def __getattribute__(self, name: str) -> Any: + # if hasattr(self, name): + # return getattr(self, name) + # logger.critical("se vedi questo messaggio: sei perduto") + # return None def init_trust_resources(self) -> None: """ diff --git a/pyeudiw/trust/default/x509.py b/pyeudiw/trust/default/x509.py index 329456ba..39e0adca 100644 --- a/pyeudiw/trust/default/x509.py +++ b/pyeudiw/trust/default/x509.py @@ -1,6 +1,6 @@ -from pyeudiw.trust.interface import IssuerTrustEvaluator +from pyeudiw.trust.interface import TrustEvaluator -class X509TrustModel(IssuerTrustEvaluator): +class X509TrustModel(TrustEvaluator): def __init__(self, **kwargs): pass From 6b5a6c1beb341e9b78c68da56a832287e871c22b Mon Sep 17 00:00:00 2001 From: Zicchio Date: Wed, 2 Oct 2024 16:37:46 +0200 Subject: [PATCH 13/13] wip: integration test review --- example/satosa/integration_test/main.py | 45 ++++++++++++++++++++----- example/satosa/pyeudiw_backend.yaml | 2 +- pyeudiw/openid4vp/vp_sd_jwt_vc.py | 1 + pyeudiw/vci/jwks_provider.py | 12 +++---- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/example/satosa/integration_test/main.py b/example/satosa/integration_test/main.py index aaf2226d..358e1d87 100644 --- a/example/satosa/integration_test/main.py +++ b/example/satosa/integration_test/main.py @@ -4,6 +4,7 @@ import datetime import base64 from bs4 import BeautifulSoup +import unittest.mock from pyeudiw.jwt import DEFAULT_SIG_KTY_MAP from pyeudiw.presentation_exchange.schemas.oid4vc_presentation_definition import PresentationDefinition @@ -201,16 +202,29 @@ # ) # End deprecated footprint VP envelop +# intercept call to issuer jwk +issuer_vct_md = { + "issuer": settings['issuer'], + "jwks": {"keys": [ISSUER_PRIVATE_JWK.as_dict()]} +} +jwt_vc_issuer_endpoint_response = requests.Response() +jwt_vc_issuer_endpoint_response.status_code = 200 +jwt_vc_issuer_endpoint_response.headers.update({"Content-Type": "application/json"}) +jwt_vc_issuer_endpoint_response._content = json.dumps(issuer_vct_md).encode('utf-8') +patcher = unittest.mock.patch("pyeudiw.vci.jwks_provider.get_http_url", return_value=[issuer_vct_md]) +patcher.start() # TODO: this does not works!! + # take relevant information from RP's EC -rp_ec_jwt = http_user_agent.get( - f'{IDP_BASEURL}/OpenID4VP/.well-known/openid-federation', - verify=False -).content.decode() -rp_ec = decode_jwt_payload(rp_ec_jwt) +# rp_ec_jwt = http_user_agent.get( +# f'{IDP_BASEURL}/OpenID4VP/.well-known/openid-federation', +# verify=False +# ).content.decode() +# rp_ec = decode_jwt_payload(rp_ec_jwt) -presentation_definition = rp_ec["metadata"]["wallet_relying_party"]["presentation_definition"] -PresentationDefinition(**presentation_definition) -assert response_uri == rp_ec["metadata"]['wallet_relying_party']["response_uris_supported"][0] +# presentation_definition = rp_ec["metadata"]["wallet_relying_party"]["presentation_definition"] +# encryption_key = rp_ec["metadata"]['wallet_relying_party']['jwks']['keys'][1] +# PresentationDefinition(**presentation_definition) +# assert response_uri == rp_ec["metadata"]['wallet_relying_party']["response_uris_supported"][0] response = { "state": red_data['state'], @@ -228,9 +242,21 @@ "aud": response_uri } } + +# This should be a public key provided somehow by the verifier/relying party, but as of now it is a private key granted by an angel +encryption_key = { + "kty": "RSA", + "d": "QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q", + "e": "AQAB", + "use": "enc", + "kid": "9Cquk0X-fNPSdePQIgQcQZtD6J0IjIRrFigW2PPK_-w", + "n": "utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPjaM3kYyImE941ggcuc495m-Fw", + "p": "2zmGXIMCEHPphw778YjVTar1eycih6fFSJ4I4bl1iq167GqO0PjlOx6CZ1-OdBTVU7HfrYRiUK_BnGRdPDn-DQghwwkB79ZdHWL14wXnpB5y-boHz_LxvjsEqXtuQYcIkidOGaMG68XNT1nM4F9a8UKFr5hHYT5_UIQSwsxlRQ0", + "q": "2jMFt2iFrdaYabdXuB4QMboVjPvbLA-IVb6_0hSG_-EueGBvgcBxdFGIZaG6kqHqlB7qMsSzdptU0vn6IgmCZnX-Hlt6c5X7JB_q91PZMLTO01pbZ2Bk58GloalCHnw_mjPh0YPviH5jGoWM5RHyl_HDDMI-UeLkzP7ImxGizrM" +} encrypted_response = JWEHelper( # RSA (EC is not fully supported todate) - JWK(rp_ec["metadata"]['wallet_relying_party']['jwks']['keys'][1]) + JWK(encryption_key) ).encrypt(response) @@ -280,4 +306,5 @@ obt_att_value = attributes[result_index].contents[0].contents[0] assert exp_att_value == obt_att_value, f"wrong attrirbute parsing expected {exp_att_value}, obtained {obt_att_value}" +patcher.stop() print('test passed') diff --git a/example/satosa/pyeudiw_backend.yaml b/example/satosa/pyeudiw_backend.yaml index 051bf88d..473238bc 100644 --- a/example/satosa/pyeudiw_backend.yaml +++ b/example/satosa/pyeudiw_backend.yaml @@ -95,7 +95,7 @@ config: module: pyeudiw.trust.default.federation class: FederationTrustModel config: - metadata_type: "openid_credential_verifier" + metadata_type: "wallet_relying_party" authority_hints: - http://127.0.0.1:8000 trust_anchors: diff --git a/pyeudiw/openid4vp/vp_sd_jwt_vc.py b/pyeudiw/openid4vp/vp_sd_jwt_vc.py index 2d1052c5..c15e0292 100644 --- a/pyeudiw/openid4vp/vp_sd_jwt_vc.py +++ b/pyeudiw/openid4vp/vp_sd_jwt_vc.py @@ -22,6 +22,7 @@ def get_issuer_name(self) -> str: iss = self.sdjwt.issuer_jwt.payload.get("iss", None) if not iss: raise Exception("missing required information in token paylaod: [iss]") + return iss def get_credentials(self) -> dict: return self.sdjwt.get_disclosed_claims() diff --git a/pyeudiw/vci/jwks_provider.py b/pyeudiw/vci/jwks_provider.py index 862b86df..b558e712 100644 --- a/pyeudiw/vci/jwks_provider.py +++ b/pyeudiw/vci/jwks_provider.py @@ -30,18 +30,18 @@ def _get_jwk_metadata(self, uri: str) -> dict: resp = get_http_url(uri, self.httpc_params) response: dict = resp[0].json() return response - except Exception: - # TODO: handle exception - pass + except Exception as e: + # TODO: handle meaningfully + raise e def _get_jwkset_from_jwkset_uri(self, jwkset_uri: str) -> list[dict]: try: resp = get_http_url(jwkset_uri, self.httpc_params) jwks: dict[Literal["keys"], list[dict]] = resp[0].json() return jwks.get("keys", []) - except Exception: - # TODO: handle exception - pass + except Exception as e: + # TODO; handle meaningfully + raise e def _obtain_jwkset_from_response_json(self, response: dict) -> list[dict]: jwks: dict[Literal["keys"], list[dict]] = response.get("jwks", None)