From f66aa68afc8efbe2f5744e52fbb937cb8dd9e267 Mon Sep 17 00:00:00 2001 From: Zicchio <33022304+Zicchio@users.noreply.github.com> Date: Fri, 18 Oct 2024 11:43:34 +0200 Subject: [PATCH] feat: support for vc+sd-jwt (#273) * sd-jwt format regexp + test + linting * wip: authn response processing * wip: vc+sd-jwt schema * wip: vc+sd-jwt * wip: unit test redo * wip: test for vc+sd-jwt * fix: miscellaneous minor fixes * chore: cleanup * chore: docs, linting, cleanup * applied some suggestions * applied come code suggestions * wip: ideas, brainstoming, etc * wip: intent clarification * wip: new trust model and vp interface * wip: backend configuration example * wip * wip: stub of response with trust model and vp parser * wip: unit tests * chore: renamed function * wip: unit tests, fixes * wip: cleanup * wip: cleanup * wip: fixes * wip: integration test review * wip: patch get connection * Apply suggestions from code review * Apply suggestions from code review * fix: changed file name * Apply suggestions from code review * wip: storage layer of trust eval * wip: storage layer + tests fixes * wip: integration tests * wip: fix python 3.12 retrocompatibility + todos * wip: exception handling --------- Co-authored-by: elisanp Co-authored-by: elisanp <118907120+elisanp@users.noreply.github.com> Co-authored-by: Giuseppe De Marco --- docs/STORAGE.md | 15 +- example/satosa/integration_test/commons.py | 109 ++--- .../cross_device_integration_test.py | 74 ++-- example/satosa/integration_test/main.py | 277 ------------ .../same_device_integration_test.py | 111 +++++ example/satosa/integration_test/settings.py | 5 +- example/satosa/pyeudiw_backend.yaml | 86 +++- pyeudiw/federation/statements.py | 12 +- .../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/federation/trust_chain_validator.py | 6 +- pyeudiw/jwk/__init__.py | 2 +- pyeudiw/jwt/parse.py | 57 +++ pyeudiw/jwt/utils.py | 30 +- pyeudiw/jwt/verification.py | 30 ++ pyeudiw/openid4vp/authorization_response.py | 66 +++ pyeudiw/openid4vp/exceptions.py | 16 + pyeudiw/openid4vp/interface.py | 55 +++ pyeudiw/openid4vp/utils.py | 42 +- pyeudiw/openid4vp/vp.py | 17 +- pyeudiw/openid4vp/vp_sd_jwt.py | 9 +- pyeudiw/openid4vp/vp_sd_jwt_vc.py | 52 +++ pyeudiw/satosa/default/openid4vp_backend.py | 16 +- pyeudiw/satosa/default/response_handler.py | 265 +++++++----- pyeudiw/satosa/exceptions.py | 28 ++ pyeudiw/satosa/schemas/config.py | 8 +- pyeudiw/satosa/utils/trust.py | 15 +- pyeudiw/sd_jwt/__init__.py | 19 +- pyeudiw/sd_jwt/exceptions.py | 8 + pyeudiw/sd_jwt/schema.py | 121 +++++- pyeudiw/sd_jwt/sd_jwt.py | 219 ++++++++++ pyeudiw/storage/base_storage.py | 16 +- pyeudiw/storage/db_engine.py | 14 +- pyeudiw/storage/mongo_storage.py | 37 +- pyeudiw/tests/openid4vp/utility.py | 41 ++ pyeudiw/tests/satosa/test_backend.py | 258 +++++++----- pyeudiw/tests/satosa/test_backend_trust.py | 28 ++ pyeudiw/tests/sd_jwt/test_sdjwt.py | 222 ++++++++++ pyeudiw/tests/sd_jwt/test_sdjwt_schema.py | 166 ++++++++ pyeudiw/tests/settings.py | 395 ++++++++++++++++-- pyeudiw/tests/trust/default/settings.py | 24 ++ .../tests/trust/default/test_direct_trust.py | 20 + pyeudiw/tests/trust/test_dynamic.py | 76 ++++ pyeudiw/tools/utils.py | 21 + pyeudiw/trust/_log.py | 4 + pyeudiw/trust/default/__init__.py | 20 + .../trust/default/direct_trust_sd_jwt_vc.py | 70 ++++ pyeudiw/trust/default/federation.py | 285 +++++++++++++ pyeudiw/trust/default/x509.py | 6 + pyeudiw/trust/dynamic.py | 126 ++++++ pyeudiw/trust/exceptions.py | 4 + pyeudiw/trust/interface.py | 35 ++ .../{openid4vp/request.py => vci/__init__.py} | 0 pyeudiw/vci/exception.py | 5 + pyeudiw/vci/jwks_provider.py | 97 +++++ pyeudiw/vci/utils.py | 29 ++ pyeudiw/x509/verify.py | 6 + 59 files changed, 3034 insertions(+), 748 deletions(-) delete mode 100644 example/satosa/integration_test/main.py create mode 100644 example/satosa/integration_test/same_device_integration_test.py rename pyeudiw/{openid4vp/redirect.py => federation/trust_chain/__init__.py} (100%) 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/jwt/verification.py create mode 100644 pyeudiw/openid4vp/authorization_response.py create mode 100644 pyeudiw/openid4vp/interface.py create mode 100644 pyeudiw/openid4vp/vp_sd_jwt_vc.py create mode 100644 pyeudiw/sd_jwt/sd_jwt.py create mode 100644 pyeudiw/tests/openid4vp/utility.py create mode 100644 pyeudiw/tests/satosa/test_backend_trust.py create mode 100644 pyeudiw/tests/sd_jwt/test_sdjwt.py create mode 100644 pyeudiw/tests/sd_jwt/test_sdjwt_schema.py create mode 100644 pyeudiw/tests/trust/default/settings.py create mode 100644 pyeudiw/tests/trust/default/test_direct_trust.py create mode 100644 pyeudiw/tests/trust/test_dynamic.py create mode 100644 pyeudiw/trust/_log.py create mode 100644 pyeudiw/trust/default/__init__.py create mode 100644 pyeudiw/trust/default/direct_trust_sd_jwt_vc.py create mode 100644 pyeudiw/trust/default/federation.py create mode 100644 pyeudiw/trust/default/x509.py create mode 100644 pyeudiw/trust/dynamic.py create mode 100644 pyeudiw/trust/interface.py rename pyeudiw/{openid4vp/request.py => vci/__init__.py} (100%) create mode 100644 pyeudiw/vci/exception.py create mode 100644 pyeudiw/vci/jwks_provider.py create mode 100644 pyeudiw/vci/utils.py diff --git a/docs/STORAGE.md b/docs/STORAGE.md index dfb44491..580ae076 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -101,11 +101,22 @@ This classes can be used as references while providing a custom implementation f "federation" : { "chain": ARRAY[EC,ES,ES], "exp": datetime, - "update": datetime + "update": datetime, + "jwks": { + "keys": ARRAY[object] + }, }, "x509": { "x5c": ARRAY[bytestring(DER), bytestring(DER), bytestring(DER)] -> contains public keys, - "exp": datetime + "exp": datetime, + "jwks": { + "keys": ARRAY[object] + }, + }, + "direct_trust_sd_jwt_vc": { + "jwks": { + "keys": ARRAY[object] + } } "metadata": object } diff --git a/example/satosa/integration_test/commons.py b/example/satosa/integration_test/commons.py index 2b72213b..6dc70f6d 100644 --- a/example/satosa/integration_test/commons.py +++ b/example/satosa/integration_test/commons.py @@ -1,21 +1,18 @@ import base64 +from bs4 import BeautifulSoup import datetime import requests -import uuid from typing import Any, Literal -from bs4 import BeautifulSoup -from sd_jwt.holder import SDJWTHolder from pyeudiw.jwk import JWK -from pyeudiw.jwt import DEFAULT_SIG_KTY_MAP, JWEHelper, JWSHelper +from pyeudiw.jwt import DEFAULT_SIG_KTY_MAP, JWEHelper from pyeudiw.jwt.utils import decode_jwt_payload -from pyeudiw.presentation_exchange.schemas.oid4vc_presentation_definition import PresentationDefinition from pyeudiw.sd_jwt import ( - # _adapt_keys, import_ec, issue_sd_jwt, load_specification_from_yaml_string ) +from pyeudiw.storage.base_storage import TrustType from pyeudiw.storage.db_engine import DBEngine from pyeudiw.tests.federation.base import ( EXP, @@ -30,10 +27,11 @@ leaf_wallet_signed, trust_chain_issuer ) -from pyeudiw.tools.utils import iat_now, exp_from_now +from sd_jwt.holder import SDJWTHolder +from saml2_sp import saml2_request -from saml2_sp import IDP_BASEURL from settings import ( + IDP_BASEURL, CONFIG_DB, RP_EID, its_trust_chain @@ -42,25 +40,19 @@ CREDENTIAL_ISSUER_JWK = JWK(leaf_cred_jwk_prot.serialize(private=True)) ISSUER_CONF = { "sd_specification": """ - user_claims: - !sd unique_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - !sd given_name: "Mario" - !sd family_name: "Rossi" - !sd birthdate: "1980-01-10" - !sd place_of_birth: - country: "IT" - locality: "Rome" - !sd tax_id_code: "TINIT-XXXXXXXXXXXXXXXX" - - holder_disclosed_claims: - { "given_name": "Mario", "family_name": "Rossi", "place_of_birth": {country: "IT", locality: "Rome"}, "tax_id_code": "TINIT-XXXXXXXXXXXXXXXX" } - - key_binding: True + !sd unique_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + !sd given_name: "Mario" + !sd family_name: "Rossi" + !sd birthdate: "1980-01-10" + !sd place_of_birth: + country: "IT" + locality: "Rome" + !sd tax_id_code: "TINIT-XXXXXXXXXXXXXXXX" """, "issuer": leaf_cred['sub'], - "default_exp": 1024 + "default_exp": 1024, + "key_binding": True } -ISSUER_PRIVATE_JWK = JWK(leaf_cred_jwk.serialize(private=True)) WALLET_PRIVATE_JWK = JWK(leaf_wallet_jwk.serialize(private=True)) WALLET_PUBLIC_JWK = JWK(leaf_wallet_jwk.serialize()) @@ -71,7 +63,7 @@ def setup_test_db_engine() -> DBEngine: def apply_trust_settings(db_engine_inst: DBEngine) -> DBEngine: db_engine_inst.add_trust_anchor( - entity_id=ta_ec['iss'], + entity_id=ta_ec["iss"], entity_configuration=ta_ec_signed, exp=EXP ) @@ -83,24 +75,32 @@ def apply_trust_settings(db_engine_inst: DBEngine) -> DBEngine: ) db_engine_inst.add_or_update_trust_attestation( - entity_id=leaf_wallet['iss'], + entity_id=leaf_wallet["iss"], attestation=leaf_wallet_signed, exp=datetime.datetime.now().isoformat() ) db_engine_inst.add_or_update_trust_attestation( - entity_id=leaf_cred['iss'], + entity_id=leaf_cred["iss"], attestation=leaf_cred_signed, exp=datetime.datetime.now().isoformat() ) + + settings = ISSUER_CONF + db_engine_inst.add_or_update_trust_attestation( + entity_id=settings["issuer"], + trust_type=TrustType.DIRECT_TRUST_SD_JWT_VC, + jwks=leaf_cred_jwk_prot.serialize()) return db_engine_inst +def create_saml_auth_request() -> str: + auth_req_url = f"{saml2_request["headers"][0][1]}&idp_hinting=wallet" + return auth_req_url def create_issuer_test_data() -> dict[Literal["jws"] | Literal["issuance"], str]: # create a SD-JWT signed by a trusted credential issuer settings = ISSUER_CONF - settings['issuer'] = leaf_cred['iss'] - settings['default_exp'] = 33 + settings["default_exp"] = 33 sd_specification = load_specification_from_yaml_string( settings["sd_specification"] ) @@ -110,16 +110,13 @@ def create_issuer_test_data() -> dict[Literal["jws"] | Literal["issuance"], str] settings, CREDENTIAL_ISSUER_JWK, WALLET_PUBLIC_JWK, - trust_chain=trust_chain_issuer + additional_headers={"typ": "vc+sd-jwt"} ) return issued_jwt -def create_holder_test_data(issued_jwt: dict[Literal["jws"] | Literal["issuance"], str], request_nonce: str) -> str: +def create_holder_test_data(issued_jwt: dict[Literal["jws"] | Literal["issuance"], str], request_nonce: str, request_aud: str) -> str: settings = ISSUER_CONF - sd_specification = load_specification_from_yaml_string( - settings["sd_specification"] - ) sdjwt_at_holder = SDJWTHolder( issued_jwt["issuance"], @@ -127,56 +124,40 @@ def create_holder_test_data(issued_jwt: dict[Literal["jws"] | Literal["issuance" ) sdjwt_at_holder.create_presentation( claims_to_disclose={ - 'tax_id_code': "TIN-that", - 'given_name': 'Raffaello', - 'family_name': 'Mascetti' + "tax_id_code": True, + "given_name": True, + "family_name": True }, - nonce=str(uuid.uuid4()), - aud=str(uuid.uuid4()), + nonce=request_nonce, + aud=request_aud, sign_alg=DEFAULT_SIG_KTY_MAP[WALLET_PRIVATE_JWK.key.kty], holder_key=( import_ec( WALLET_PRIVATE_JWK.key.priv_key, kid=WALLET_PRIVATE_JWK.kid ) - if sd_specification.get("key_binding", False) + if settings.get("key_binding", False) else None ) ) - data = { - "iss": "https://wallet-provider.example.org/instance/vbeXJksM45xphtANnCiG6mCyuU4jfGNzopGuKvogg9c", - "jti": str(uuid.uuid4()), - "aud": "https://relying-party.example.org/callback", - "iat": iat_now(), - "exp": exp_from_now(minutes=5), - "nonce": request_nonce, - "vp": sdjwt_at_holder.sd_jwt_presentation, - } - - vp_token = JWSHelper(WALLET_PRIVATE_JWK).sign( - data, - protected={"typ": "JWT"} - ) + vp_token = sdjwt_at_holder.sd_jwt_presentation return vp_token - -def create_authorize_response(vp_token: str, state: str, nonce: str, response_uri: str) -> str: - # take relevant information from RP's entity configuration +def create_authorize_response(vp_token: str, state: str, response_uri: str) -> str: + # Extract public key from RP's entity configuration client = requests.Session() rp_ec_jwt = client.get( - f'{IDP_BASEURL}/OpenID4VP/.well-known/openid-federation', + 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] + assert response_uri == rp_ec["metadata"]["wallet_relying_party"]["response_uris_supported"][0] + encryption_key = rp_ec["metadata"]["wallet_relying_party"]["jwks"]["keys"][1] response = { "state": state, - "nonce": nonce, "vp_token": vp_token, "presentation_submission": { "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", @@ -192,8 +173,8 @@ def create_authorize_response(vp_token: str, state: str, nonce: str, response_ur } } encrypted_response = JWEHelper( - # RSA (EC is not fully supported todate) - JWK(rp_ec["metadata"]['wallet_relying_party']['jwks']['keys'][1]) + # RSA (EC is not fully supported to date) + JWK(encryption_key) ).encrypt(response) return encrypted_response diff --git a/example/satosa/integration_test/cross_device_integration_test.py b/example/satosa/integration_test/cross_device_integration_test.py index a48ecf91..4b2034d5 100644 --- a/example/satosa/integration_test/cross_device_integration_test.py +++ b/example/satosa/integration_test/cross_device_integration_test.py @@ -1,31 +1,27 @@ -import urllib.parse -import requests -import urllib from bs4 import BeautifulSoup import re +import requests +import urllib.parse from pyeudiw.jwt.utils import decode_jwt_payload from commons import ( ISSUER_CONF, + setup_test_db_engine, apply_trust_settings, + create_saml_auth_request, create_authorize_response, create_holder_test_data, create_issuer_test_data, - extract_saml_attributes, - setup_test_db_engine, + extract_saml_attributes ) -from saml2_sp import saml2_request from settings import TIMEOUT_S +# put a trust attestation related itself into the storage +# this is then used as trust_chain header parameter in the signed request object db_engine_inst = setup_test_db_engine() db_engine_inst = apply_trust_settings(db_engine_inst) -headers_browser = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36" -} - - def _verify_status(status_uri: str, expected_code: int): status_check = http_user_agent.get( status_uri, @@ -38,16 +34,16 @@ def _verify_status(status_uri: str, expected_code: int): def _extract_request_uri(bs: BeautifulSoup) -> str: # Request URI is extracted by parsing the QR code in the response page qrcode_element = list(bs.find(id="content-qrcode-payload").children)[1] - qrcode_text = qrcode_element.get('contents') - request_uri = urllib.parse.parse_qs(qrcode_text)['request_uri'][0] + qrcode_text = qrcode_element.get("contents") + request_uri = urllib.parse.parse_qs(qrcode_text)["request_uri"][0] return request_uri def _extract_status_uri(bs: BeautifulSoup) -> str: # Status uri is extracted by parsing a matching regexp in the