From f0ddcdcb905f56e4dc37d5356cc24613af5a9098 Mon Sep 17 00:00:00 2001 From: Ivan Wei Date: Wed, 24 Jul 2024 16:22:34 -0400 Subject: [PATCH] Modular credential format support for oid4vci (#772) * modular credential format support for oid4vci Signed-off-by: Ivan Wei * github checks Signed-off-by: Ivan Wei * ruff linting fixes Signed-off-by: Ivan Wei * "lite" version of credential format plugins Signed-off-by: Ivan Wei * review fixes Signed-off-by: Ivan Wei * fix out-sync portry.lock file Signed-off-by: Ivan Wei --------- Signed-off-by: Ivan Wei --- .gitignore | 197 +++++++++++++++++- jwt_vc_json/README.md | 7 + jwt_vc_json/jwt_vc_json/__init__.py | 1 + jwt_vc_json/jwt_vc_json/v1_0/__init__.py | 6 + .../jwt_vc_json/v1_0/cred_processor.py | 64 ++++++ .../jwt_vc_json/v1_0/tests/__init__.py | 1 + .../jwt_vc_json/v1_0/tests/conftest.py | 62 ++++++ .../v1_0/tests/test_cred_processor.py | 29 +++ .../jwt_vc_json/v1_0/tests/test_init.py | 12 ++ jwt_vc_json/pyproject.toml | 88 ++++++++ mso_mdoc/README.md | 7 + mso_mdoc/mso_mdoc/__init__.py | 1 + mso_mdoc/mso_mdoc/v1_0/__init__.py | 6 + mso_mdoc/mso_mdoc/v1_0/cred_processor.py | 53 +++++ mso_mdoc/mso_mdoc/v1_0/mdoc/__init__.py | 18 ++ mso_mdoc/mso_mdoc/v1_0/mdoc/exceptions.py | 20 ++ mso_mdoc/mso_mdoc/v1_0/mdoc/issuer.py | 144 +++++++++++++ mso_mdoc/mso_mdoc/v1_0/mdoc/verifier.py | 102 +++++++++ mso_mdoc/mso_mdoc/v1_0/mso/__init__.py | 6 + mso_mdoc/mso_mdoc/v1_0/mso/issuer.py | 124 +++++++++++ mso_mdoc/mso_mdoc/v1_0/mso/verifier.py | 63 ++++++ mso_mdoc/mso_mdoc/v1_0/routes.py | 157 ++++++++++++++ mso_mdoc/mso_mdoc/v1_0/tests/__init__.py | 1 + mso_mdoc/mso_mdoc/v1_0/tests/conftest.py | 116 +++++++++++ mso_mdoc/mso_mdoc/v1_0/tests/mdoc/__init__.py | 1 + .../mso_mdoc/v1_0/tests/mdoc/test_issuer.py | 12 ++ .../mso_mdoc/v1_0/tests/mdoc/test_verifier.py | 12 ++ mso_mdoc/mso_mdoc/v1_0/tests/mso/__init__.py | 1 + .../mso_mdoc/v1_0/tests/mso/test_issuer.py | 32 +++ .../mso_mdoc/v1_0/tests/mso/test_verifier.py | 17 ++ mso_mdoc/mso_mdoc/v1_0/tests/test_x509.py | 28 +++ mso_mdoc/mso_mdoc/v1_0/x509.py | 30 +++ mso_mdoc/pyproject.toml | 93 +++++++++ oid4vci/.DS_Store | Bin 6148 -> 0 bytes oid4vci/README.md | 2 + oid4vci/demo/.DS_Store | Bin 6148 -> 0 bytes oid4vci/demo/docker-compose.yaml | 1 + oid4vci/docker/Dockerfile | 18 +- oid4vci/integration/Dockerfile | 1 + oid4vci/integration/docker-compose.yml | 6 +- oid4vci/integration/poetry.lock | 2 +- oid4vci/oid4vci/.DS_Store | Bin 6148 -> 0 bytes oid4vci/oid4vci/config.py | 11 +- oid4vci/oid4vci/cred_processor.py | 39 ++++ oid4vci/oid4vci/models/exchange.py | 2 +- oid4vci/oid4vci/models/supported_cred.py | 3 +- oid4vci/oid4vci/pop_result.py | 15 ++ oid4vci/oid4vci/public_routes.py | 88 +++----- oid4vci/oid4vci/routes.py | 12 +- oid4vci/oid4vci/tests/routes/conftest.py | 1 + oid4vci/poetry.lock | 24 ++- oid4vci/pyproject.toml | 5 +- 52 files changed, 1660 insertions(+), 81 deletions(-) create mode 100644 jwt_vc_json/README.md create mode 100644 jwt_vc_json/jwt_vc_json/__init__.py create mode 100644 jwt_vc_json/jwt_vc_json/v1_0/__init__.py create mode 100644 jwt_vc_json/jwt_vc_json/v1_0/cred_processor.py create mode 100644 jwt_vc_json/jwt_vc_json/v1_0/tests/__init__.py create mode 100644 jwt_vc_json/jwt_vc_json/v1_0/tests/conftest.py create mode 100644 jwt_vc_json/jwt_vc_json/v1_0/tests/test_cred_processor.py create mode 100644 jwt_vc_json/jwt_vc_json/v1_0/tests/test_init.py create mode 100644 jwt_vc_json/pyproject.toml create mode 100644 mso_mdoc/README.md create mode 100644 mso_mdoc/mso_mdoc/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/cred_processor.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mdoc/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mdoc/exceptions.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mdoc/issuer.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mdoc/verifier.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mso/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mso/issuer.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/mso/verifier.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/routes.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/conftest.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/mdoc/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_issuer.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_verifier.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/mso/__init__.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/mso/test_issuer.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/mso/test_verifier.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/tests/test_x509.py create mode 100644 mso_mdoc/mso_mdoc/v1_0/x509.py create mode 100644 mso_mdoc/pyproject.toml delete mode 100644 oid4vci/.DS_Store delete mode 100644 oid4vci/demo/.DS_Store delete mode 100644 oid4vci/oid4vci/.DS_Store create mode 100644 oid4vci/oid4vci/cred_processor.py create mode 100644 oid4vci/oid4vci/pop_result.py diff --git a/.gitignore b/.gitignore index 0dd219fc6..4c995e74e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,200 @@ +### +### Python +### + +# Byte-compiled / optimized / DLL files __pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ .pytest_cache/ +test-reports/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +*.lock +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +Pipfile +Pipfile.lock + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### +### Visual Studio Code +### + +.vscode/ + +### +### MacOS +### + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### +### IntelliJ IDEs +### + +.idea/* +**/.idea/* + +### +### Windows +### + +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Docs build +_build/ +**/*.iml + +# Open API build +open-api/.build + +# devcontainer +.pytest.ini + +# project specific .ruff_cache/ .test-reports/ **/test-reports/ -.coverage -coverage.xml settings.json -.env \ No newline at end of file diff --git a/jwt_vc_json/README.md b/jwt_vc_json/README.md new file mode 100644 index 000000000..0db5ae895 --- /dev/null +++ b/jwt_vc_json/README.md @@ -0,0 +1,7 @@ +# JWT_VC_JSON credential format plugin + +This plugin provides `jwt_vc_json` credential support for the OID4VCI plugin. It acts as a module, dynamically loaded by the OID4VCI plugin, takes input parameters, and constructs and signs `jwt_vc_json` credentials. + +## Configuration: + +No configuration is required for this plugin. diff --git a/jwt_vc_json/jwt_vc_json/__init__.py b/jwt_vc_json/jwt_vc_json/__init__.py new file mode 100644 index 000000000..63e11e986 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/__init__.py @@ -0,0 +1 @@ +"""jwt_vc_json credential handler plugin.""" \ No newline at end of file diff --git a/jwt_vc_json/jwt_vc_json/v1_0/__init__.py b/jwt_vc_json/jwt_vc_json/v1_0/__init__.py new file mode 100644 index 000000000..a3c103ed6 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/v1_0/__init__.py @@ -0,0 +1,6 @@ +"""Initialize processor.""" + +from .cred_processor import CredProcessor + + +cred_processor = CredProcessor() diff --git a/jwt_vc_json/jwt_vc_json/v1_0/cred_processor.py b/jwt_vc_json/jwt_vc_json/v1_0/cred_processor.py new file mode 100644 index 000000000..3d94f0d47 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/v1_0/cred_processor.py @@ -0,0 +1,64 @@ +"""Issue a jwt_vc_json credential.""" + +import datetime +import logging +import uuid + +from aries_cloudagent.admin.request_context import AdminRequestContext +from aries_cloudagent.wallet.jwt import jwt_sign + +from oid4vci.models.exchange import OID4VCIExchangeRecord +from oid4vci.models.supported_cred import SupportedCredential +from oid4vci.public_routes import types_are_subset +from oid4vci.pop_result import PopResult +from oid4vci.cred_processor import ICredProcessor, CredIssueError + +LOGGER = logging.getLogger(__name__) + + +class CredProcessor(ICredProcessor): + """Credential processor class for jwt_vc_json format.""" + + async def issue_cred( + self, + body: any, + supported: SupportedCredential, + ex_record: OID4VCIExchangeRecord, + pop: PopResult, + context: AdminRequestContext, + ): + """Return signed credential in JWT format.""" + if not types_are_subset(body.get("types"), supported.format_data.get("types")): + raise CredIssueError("Requested types does not match offer.") + + current_time = datetime.datetime.now(datetime.timezone.utc) + current_time_unix_timestamp = int(current_time.timestamp()) + formatted_time = current_time.strftime("%Y-%m-%dT%H:%M:%SZ") + cred_id = str(uuid.uuid4()) + + # note: Some wallets require that the "jti" and "id" are a uri + payload = { + "vc": { + **(supported.vc_additional_data or {}), + "id": f"urn:uuid:{cred_id}", + "issuer": ex_record.issuer_id, + "issuanceDate": formatted_time, + "credentialSubject": { + **(ex_record.credential_subject or {}), + "id": pop.holder_kid, + }, + }, + "iss": ex_record.issuer_id, + "nbf": current_time_unix_timestamp, + "jti": f"urn:uuid:{cred_id}", + "sub": pop.holder_kid, + } + + jws = await jwt_sign( + context.profile, + {}, + payload, + verification_method=ex_record.verification_method, + ) + + return jws diff --git a/jwt_vc_json/jwt_vc_json/v1_0/tests/__init__.py b/jwt_vc_json/jwt_vc_json/v1_0/tests/__init__.py new file mode 100644 index 000000000..5a595d474 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/v1_0/tests/__init__.py @@ -0,0 +1 @@ +"""CredentialProcessor test.""" diff --git a/jwt_vc_json/jwt_vc_json/v1_0/tests/conftest.py b/jwt_vc_json/jwt_vc_json/v1_0/tests/conftest.py new file mode 100644 index 000000000..5bb52df65 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/v1_0/tests/conftest.py @@ -0,0 +1,62 @@ +import pytest +from unittest.mock import MagicMock + +from aries_cloudagent.admin.request_context import AdminRequestContext + +from oid4vci.models.exchange import OID4VCIExchangeRecord +from oid4vci.models.supported_cred import SupportedCredential +from oid4vci.public_routes import PopResult + + +@pytest.fixture +def body(): + items = {"format": "jwt_vc_json", "types": ["OntarioTestPhotoCard"], "proof": {}} + mock = MagicMock() + mock.__getitem__ = lambda _, k: items[k] + yield mock + + +@pytest.fixture +def supported(): + yield SupportedCredential( + format_data={"types": ["VerifiableCredential", "PhotoCard"]}, + vc_additional_data={ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://issuer-controller1.stg.ngrok.io/url/schema/photo-card.jsonld", + ], + "type": ["VerifiableCredential", "PhotoCard"], + }, + ) + + +@pytest.fixture +def ex_record(): + yield OID4VCIExchangeRecord( + state=OID4VCIExchangeRecord.STATE_OFFER_CREATED, + verification_method="did:example:123#key-1", + issuer_id="did:example:123", + supported_cred_id="456", + credential_subject={"name": "alice"}, + nonce="789", + pin="000", + code="111", + token="222", + ) + + +@pytest.fixture +def pop(): + yield PopResult( + headers=None, + payload=None, + verified=True, + holder_kid="did:key:example-kid#0", + holder_jwk=None, + ) + + +@pytest.fixture +def context(): + """Test AdminRequestContext.""" + yield AdminRequestContext.test_context() diff --git a/jwt_vc_json/jwt_vc_json/v1_0/tests/test_cred_processor.py b/jwt_vc_json/jwt_vc_json/v1_0/tests/test_cred_processor.py new file mode 100644 index 000000000..4c699cbd4 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/v1_0/tests/test_cred_processor.py @@ -0,0 +1,29 @@ +import pytest +from aries_cloudagent.admin.request_context import AdminRequestContext + +from oid4vci.models.exchange import OID4VCIExchangeRecord +from oid4vci.models.supported_cred import SupportedCredential +from oid4vci.public_routes import PopResult + +from ..cred_processor import CredProcessor + + +class TestCredentialProcessor: + """Tests for CredentialProcessor.""" + + @pytest.mark.asyncio + async def test_issue_credential( + self, + body: any, + supported: SupportedCredential, + ex_record: OID4VCIExchangeRecord, + pop: PopResult, + context: AdminRequestContext, + ): + """Test issue_credential method.""" + + cred_processor = CredProcessor() + + jws = cred_processor.issue_cred(body, supported, ex_record, pop, context) + + assert jws diff --git a/jwt_vc_json/jwt_vc_json/v1_0/tests/test_init.py b/jwt_vc_json/jwt_vc_json/v1_0/tests/test_init.py new file mode 100644 index 000000000..1a8020b42 --- /dev/null +++ b/jwt_vc_json/jwt_vc_json/v1_0/tests/test_init.py @@ -0,0 +1,12 @@ +import pytest + +from ..cred_processor import CredProcessor + + +@pytest.mark.asyncio +async def test__init__(): + """Test __init.""" + + cred_processor = CredProcessor() + + assert cred_processor diff --git a/jwt_vc_json/pyproject.toml b/jwt_vc_json/pyproject.toml new file mode 100644 index 000000000..e793824af --- /dev/null +++ b/jwt_vc_json/pyproject.toml @@ -0,0 +1,88 @@ +[tool.poetry] +name = "jwt_vc_json" +version = "0.1.0" +description = "jwt_vc_json credential handler plugin" +authors = [] + +[tool.poetry.dependencies] +python = "^3.9" + +# Define ACA-Py as an optional/extra dependancy so it can be +# explicitly installed with the plugin if desired. +aries-cloudagent = { version = ">=0.10.3, < 1.0.0", optional = true } +oid4vci = { path = "../oid4vci", optional = true, develop = true } + +[tool.poetry.extras] +aca-py = ["aries-cloudagent"] +oid4vci = ["oid4vci"] + +[tool.poetry.dev-dependencies] +ruff = "^0.5.0" +black = "~24.4.2" +pytest = "^8.2.0" +pytest-asyncio = "~0.23.7" +pytest-cov = "^5.0.0" +pytest-ruff = "^0.3.2" +setuptools = "^70.3.0" + +[tool.poetry.group.integration.dependencies] +aries-askar = { version = "~0.3.0" } +indy-credx = { version = "~1.1.1" } +indy-vdr = { version = "~0.4.1" } +ursa-bbs-signatures = { version = "~1.0.1" } +python3-indy = { version = "^1.11.1" } +anoncreds = { version = "0.2.0" } + +[tool.ruff] +line-length = 90 + +[tool.ruff.lint] +select = ["E", "F", "C", "D"] +ignore = [ + # Google Python Doc Style + "D203", "D204", "D213", "D215", "D400", "D401", "D404", "D406", "D407", + "D408", "D409", "D413", + "D202", # Allow blank line after docstring + "D104", # Don't require docstring in public package + # Things that we should fix, but are too much work right now + "D417", "C901", +] + +[tool.ruff.lint.per-file-ignores] +"**/{tests}/*" = ["F841", "D", "E501"] + +[tool.pytest.ini_options] +testpaths = "jwt_vc_json" +addopts = """ + -p no:warnings + --quiet --junitxml=./.test-reports/junit.xml + --cov-config .coveragerc --cov=jwt_vc_json --cov-report term --cov-report xml +""" +markers = [] +junit_family = "xunit1" +asyncio_mode = "auto" + +[tool.coverage.run] +omit = [ + "*/tests/*", + "docker/*", + "integration/*", + "*/definition.py" +] +data_file = ".test-reports/.coverage" + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "@abstract" +] +precision = 2 +skip_covered = true +show_missing = true + +[tool.coverage.xml] +output = ".test-reports/coverage.xml" + +[build-system] +requires = ["setuptools", "poetry-core>=1.2"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/mso_mdoc/README.md b/mso_mdoc/README.md new file mode 100644 index 000000000..7f12545aa --- /dev/null +++ b/mso_mdoc/README.md @@ -0,0 +1,7 @@ +# MSO_MDOC credential format plugin + +This plugin provides `mso_mdoc` credential support for the OID4VCI plugin. It acts as a module, dynamically loaded by the OID4VCI plugin, takes input parameters, and constructs and signs `mso_mdoc` credentials. + +## Configuration: + +No configuration is required for this plugin. diff --git a/mso_mdoc/mso_mdoc/__init__.py b/mso_mdoc/mso_mdoc/__init__.py new file mode 100644 index 000000000..410ec3dd1 --- /dev/null +++ b/mso_mdoc/mso_mdoc/__init__.py @@ -0,0 +1 @@ +"""MSO_MDOC Crendential Handler Plugin.""" \ No newline at end of file diff --git a/mso_mdoc/mso_mdoc/v1_0/__init__.py b/mso_mdoc/mso_mdoc/v1_0/__init__.py new file mode 100644 index 000000000..a3c103ed6 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/__init__.py @@ -0,0 +1,6 @@ +"""Initialize processor.""" + +from .cred_processor import CredProcessor + + +cred_processor = CredProcessor() diff --git a/mso_mdoc/mso_mdoc/v1_0/cred_processor.py b/mso_mdoc/mso_mdoc/v1_0/cred_processor.py new file mode 100644 index 000000000..77211bd99 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/cred_processor.py @@ -0,0 +1,53 @@ +"""Issue a mso_mdoc credential.""" + +import logging +import json +import re + +from aries_cloudagent.admin.request_context import AdminRequestContext + +from oid4vci.models.exchange import OID4VCIExchangeRecord +from oid4vci.models.supported_cred import SupportedCredential +from oid4vci.pop_result import PopResult +from oid4vci.cred_processor import ICredProcessor, CredIssueError + +from .mdoc import mso_mdoc_sign + +LOGGER = logging.getLogger(__name__) + + +class CredProcessor(ICredProcessor): + """Credential processor class for mso_mdoc credential format.""" + + async def issue_cred( + self, + body: any, + supported: SupportedCredential, + ex_record: OID4VCIExchangeRecord, + pop: PopResult, + context: AdminRequestContext, + ): + """Return signed credential in COBR format.""" + if body.get("doctype") != supported.format_data.get("doctype"): + raise CredIssueError("Requested doctype does not match offer.") + + try: + headers = { + "doctype": supported.format_data.get("doctype"), + "deviceKey": re.sub( + "did:(.+?):(.+?)#(.*)", + "\\2", + json.dumps(pop.holder_jwk or pop.holder_kid), + ), + } + did = None + verification_method = ex_record.verification_method + payload = ex_record.credential_subject + mso_mdoc = await mso_mdoc_sign( + context.profile, headers, payload, did, verification_method + ) + mso_mdoc = mso_mdoc[2:-1] if mso_mdoc.startswith("b'") else None + except Exception as ex: + raise CredIssueError("Failed to issue credential") from ex + + return mso_mdoc diff --git a/mso_mdoc/mso_mdoc/v1_0/mdoc/__init__.py b/mso_mdoc/mso_mdoc/v1_0/mdoc/__init__.py new file mode 100644 index 000000000..9ad48b8cf --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mdoc/__init__.py @@ -0,0 +1,18 @@ +"""MDoc module.""" + +from .issuer import mso_mdoc_sign, mdoc_sign +from .verifier import mso_mdoc_verify, mdoc_verify, MdocVerifyResult +from .exceptions import MissingPrivateKey, MissingIssuerAuth +from .exceptions import NoDocumentTypeProvided, NoSignedDocumentProvided + +__all__ = [ + mso_mdoc_sign, + mdoc_sign, + mso_mdoc_verify, + mdoc_verify, + MdocVerifyResult, + MissingPrivateKey, + MissingIssuerAuth, + NoDocumentTypeProvided, + NoSignedDocumentProvided, +] diff --git a/mso_mdoc/mso_mdoc/v1_0/mdoc/exceptions.py b/mso_mdoc/mso_mdoc/v1_0/mdoc/exceptions.py new file mode 100644 index 000000000..fd5f8fed4 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mdoc/exceptions.py @@ -0,0 +1,20 @@ +"""Exceptions module.""" + +class MissingPrivateKey(Exception): + """Missing private key error.""" + pass + + +class NoDocumentTypeProvided(Exception): + """No document type error.""" + pass + + +class NoSignedDocumentProvided(Exception): + """No signed document provider error.""" + pass + + +class MissingIssuerAuth(Exception): + """Missing issuer authentication error.""" + pass diff --git a/mso_mdoc/mso_mdoc/v1_0/mdoc/issuer.py b/mso_mdoc/mso_mdoc/v1_0/mdoc/issuer.py new file mode 100644 index 000000000..519e55085 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mdoc/issuer.py @@ -0,0 +1,144 @@ +"""Operations supporting mso_mdoc issuance.""" + +import os +import json +import logging +import cbor2 +from binascii import hexlify +from pycose.keys import CoseKey +from pydid import DIDUrl +from typing import Any, Mapping, Optional + +from aries_cloudagent.core.profile import Profile +from aries_cloudagent.wallet.default_verification_key_strategy import ( + BaseVerificationKeyStrategy, +) +from aries_cloudagent.wallet.base import BaseWallet +from aries_cloudagent.wallet.util import b64_to_bytes, bytes_to_b64 + +from ..mso import MsoIssuer +from ..x509 import selfsigned_x509cert + +LOGGER = logging.getLogger(__name__) + + +def dict_to_b64(value: Mapping[str, Any]) -> str: + """Encode a dictionary as a b64 string.""" + return bytes_to_b64(json.dumps(value).encode(), urlsafe=True, pad=False) + + +def b64_to_dict(value: str) -> Mapping[str, Any]: + """Decode a dictionary from a b64 encoded value.""" + return json.loads(b64_to_bytes(value, urlsafe=True)) + + +def nym_to_did(value: str) -> str: + """Return a did from nym if passed value is nym, else return value.""" + return value if value.startswith("did:") else f"did:sov:{value}" + + +def did_lookup_name(value: str) -> str: + """Return the value used to lookup a DID in the wallet. + + If value is did:sov, return the unqualified value. Else, return value. + """ + return value.split(":", 3)[2] if value.startswith("did:sov:") else value + + +async def mso_mdoc_sign( + profile: Profile, + headers: Mapping[str, Any], + payload: Mapping[str, Any], + did: Optional[str] = None, + verification_method: Optional[str] = None, +) -> str: + """Create a signed mso_mdoc given headers, payload, and signing DID or DID URL.""" + if verification_method is None: + if did is None: + raise ValueError("did or verificationMethod required.") + + did = nym_to_did(did) + + verkey_strat = profile.inject(BaseVerificationKeyStrategy) + verification_method = await verkey_strat.get_verification_method_id_for_did( + did, profile + ) + if not verification_method: + raise ValueError("Could not determine verification method from DID") + else: + # We look up keys by did for now + did = DIDUrl.parse(verification_method).did + if not did: + raise ValueError("DID URL must be absolute") + + async with profile.session() as session: + wallet = session.inject(BaseWallet) + LOGGER.info(f"mso_mdoc sign: {did}") + + did_info = await wallet.get_local_did(did_lookup_name(did)) + key_pair = await wallet._session.handle.fetch_key(did_info.verkey) + jwk_bytes = key_pair.key.get_jwk_secret() + jwk = json.loads(jwk_bytes) + + return mdoc_sign(jwk, headers, payload) + + +def mdoc_sign( + jwk: dict, headers: Mapping[str, Any], payload: Mapping[str, Any] +) -> str: + """Create a signed mso_mdoc given headers, payload, and private key.""" + pk_dict = { + "KTY": jwk.get("kty") or "", # OKP, EC + "CURVE": jwk.get("crv") or "", # ED25519, P_256 + "ALG": "EdDSA" if jwk.get("kty") == "OKP" else "ES256", + "D": b64_to_bytes(jwk.get("d") or "", True), # EdDSA + "X": b64_to_bytes(jwk.get("x") or "", True), # EdDSA, EcDSA + "Y": b64_to_bytes(jwk.get("y") or "", True), # EcDSA + "KID": os.urandom(32), + } + cose_key = CoseKey.from_dict(pk_dict) + + if isinstance(headers, dict): + doctype = headers.get("doctype") or "" + device_key = headers.get("deviceKey") or "" + else: + raise ValueError("missing headers.") + + if isinstance(payload, dict): + doctype = headers.get("doctype") + data = [{"doctype": doctype, "data": payload}] + else: + raise ValueError("missing payload.") + + documents = [] + for doc in data: + _cert = selfsigned_x509cert(private_key=cose_key) + msoi = MsoIssuer(data=doc["data"], private_key=cose_key, x509_cert=_cert) + mso = msoi.sign(device_key=device_key, doctype=doctype) + issuer_auth = mso.encode() + issuer_auth = cbor2.loads(issuer_auth).value + issuer_auth[2] = cbor2.dumps(cbor2.CBORTag(24, issuer_auth[2])) + document = { + "docType": doctype, + "issuerSigned": { + "nameSpaces": { + ns: [cbor2.CBORTag(24, cbor2.dumps(v)) for k, v in dgst.items()] + for ns, dgst in msoi.disclosure_map.items() + }, + "issuerAuth": issuer_auth, + }, + # this is required during the presentation. + # 'deviceSigned': { + # # TODO + # } + } + documents.append(document) + + signed = { + "version": "1.0", + "documents": documents, + "status": 0, + } + signed_hex = hexlify(cbor2.dumps(signed)) + + return f"{signed_hex}" diff --git a/mso_mdoc/mso_mdoc/v1_0/mdoc/verifier.py b/mso_mdoc/mso_mdoc/v1_0/mdoc/verifier.py new file mode 100644 index 000000000..003594014 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mdoc/verifier.py @@ -0,0 +1,102 @@ +"""Operations supporting mso_mdoc creation and verification.""" + +import logging +import re +from binascii import unhexlify +from typing import Any, Mapping +from marshmallow import fields +from aries_cloudagent.core.profile import Profile +from aries_cloudagent.messaging.models.base import BaseModel, BaseModelSchema +from aries_cloudagent.wallet.error import WalletNotFoundError +from aries_cloudagent.wallet.base import BaseWallet +from aries_cloudagent.wallet.util import bytes_to_b58 +import cbor2 +from cbor_diag import cbor2diag +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey + +from ..mso import MsoVerifier + +LOGGER = logging.getLogger(__name__) + + +class MdocVerifyResult(BaseModel): + """Result from verify.""" + + class Meta: + """MdocVerifyResult metadata.""" + + schema_class = "MdocVerifyResultSchema" + + def __init__( + self, + headers: Mapping[str, Any], + payload: Mapping[str, Any], + valid: bool, + kid: str, + ): + """Initialize a MdocVerifyResult instance.""" + self.headers = headers + self.payload = payload + self.valid = valid + self.kid = kid + + +class MdocVerifyResultSchema(BaseModelSchema): + """MdocVerifyResult schema.""" + + class Meta: + """MdocVerifyResultSchema metadata.""" + + model_class = MdocVerifyResult + + headers = fields.Dict( + required=False, metadata={"description": "Headers from verified mso_mdoc."} + ) + payload = fields.Dict( + required=True, metadata={"description": "Payload from verified mso_mdoc"} + ) + valid = fields.Bool(required=True) + kid = fields.Str(required=False, metadata={"description": "kid of signer"}) + error = fields.Str(required=False, metadata={"description": "Error text"}) + + +async def mso_mdoc_verify(profile: Profile, mdoc_str: str) -> MdocVerifyResult: + """Verify a mso_mdoc CBOR string.""" + result = mdoc_verify(mdoc_str) + verkey = result.kid + + async with profile.session() as session: + wallet = session.inject(BaseWallet) + try: + did_info = await wallet.get_local_did_for_verkey(verkey) + except WalletNotFoundError: + did_info = None + verification_method = did_info.did if did_info else "" + result.kid = verification_method + + return result + + +def mdoc_verify(mdoc_str: str) -> MdocVerifyResult: + """Verify a mso_mdoc CBOR string.""" + mdoc_bytes = unhexlify(mdoc_str) + mso_mdoc = cbor2.loads(mdoc_bytes) + mso_verifier = MsoVerifier(mso_mdoc["documents"][0]["issuerSigned"]["issuerAuth"]) + valid = mso_verifier.verify_signature() + + headers = {} + mdoc_str = str(cbor2diag(mdoc_bytes)).replace("\n", "").replace("h'", "'") + mdoc_str = re.sub(r'\s+(?=(?:[^"]*"[^"]*")*[^"]*$)', "", mdoc_str) + payload = {"mso_mdoc": mdoc_str} + + if isinstance(mso_verifier.public_key, Ed25519PublicKey): + public_bytes = mso_verifier.public_key.public_bytes_raw() + elif isinstance(mso_verifier.public_key, EllipticCurvePublicKey): + public_bytes = mso_verifier.public_key.public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) + verkey = bytes_to_b58(public_bytes) + + return MdocVerifyResult(headers, payload, valid, verkey) diff --git a/mso_mdoc/mso_mdoc/v1_0/mso/__init__.py b/mso_mdoc/mso_mdoc/v1_0/mso/__init__.py new file mode 100644 index 000000000..c19f909dd --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mso/__init__.py @@ -0,0 +1,6 @@ +"""MSO module.""" + +from .issuer import MsoIssuer +from .verifier import MsoVerifier + +__all__ = [MsoIssuer, MsoVerifier] diff --git a/mso_mdoc/mso_mdoc/v1_0/mso/issuer.py b/mso_mdoc/mso_mdoc/v1_0/mso/issuer.py new file mode 100644 index 000000000..b6c0deb40 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mso/issuer.py @@ -0,0 +1,124 @@ +"""MsoIssuer helper class to issue a mso.""" + +from typing import Union +import logging +from datetime import datetime, timedelta, timezone +import random +import hashlib +import os +import cbor2 +from pycose.headers import Algorithm, KID +from pycose.keys import CoseKey +from pycose.messages import Sign1Message + +LOGGER = logging.getLogger(__name__) +DIGEST_SALT_LENGTH = 32 +CBORTAGS_ATTR_MAP = {"birth_date": 1004, "expiry_date": 1004, "issue_date": 1004} + + +def shuffle_dict(d: dict): + """Shuffle a dictionary.""" + keys = list(d.keys()) + for i in range(random.randint(3, 27)): # nosec: B311 + random.shuffle(keys) + return {key: d[key] for key in keys} + + +class MsoIssuer: + """MsoIssuer helper class to issue a mso.""" + + def __init__( + self, + data: dict, + private_key: CoseKey, + x509_cert: str, + digest_alg: str = "sha256", + ): + """Constructor.""" + + self.data: dict = data + self.hash_map: dict = {} + self.disclosure_map: dict = {} + self.digest_alg: str = digest_alg + self.private_key: CoseKey = private_key + self.x509_cert = x509_cert + + hashfunc = getattr(hashlib, self.digest_alg) + + digest_cnt = 0 + for ns, values in data.items(): + if not isinstance(values, dict): + continue + self.disclosure_map[ns] = {} + self.hash_map[ns] = {} + + for k, v in shuffle_dict(values).items(): + _rnd_salt = os.urandom(32) + _value_cbortag = CBORTAGS_ATTR_MAP.get(k, None) + + if _value_cbortag: + v = cbor2.CBORTag(_value_cbortag, v) + + self.disclosure_map[ns][digest_cnt] = { + "digestID": digest_cnt, + "random": _rnd_salt, + "elementIdentifier": k, + "elementValue": v, + } + self.hash_map[ns][digest_cnt] = hashfunc( + cbor2.dumps( + cbor2.CBORTag( + 24, self.disclosure_map[ns][digest_cnt] + ) + ) + ).digest() + + digest_cnt += 1 + + def format_datetime_repr(self, dt: datetime) -> str: + """Format a datetime object to a string representation.""" + return dt.isoformat().split(".")[0] + "Z" + + def sign( + self, + device_key: Union[dict, None] = None, + valid_from: Union[None, datetime] = None, + doctype: str = None, + ) -> Sign1Message: + """Sign a mso and returns it in Sign1Message type.""" + utcnow = datetime.now(timezone.utc) + exp = utcnow + timedelta(hours=(24 * 365)) + + payload = { + "version": "1.0", + "digestAlgorithm": self.digest_alg, + "valueDigests": self.hash_map, + "deviceKeyInfo": {"deviceKey": device_key}, + "docType": doctype or list(self.hash_map)[0], + "validityInfo": { + "signed": cbor2.dumps( + cbor2.CBORTag(0, self.format_datetime_repr(utcnow)) + ), + "validFrom": cbor2.dumps( + cbor2.CBORTag(0, self.format_datetime_repr(valid_from or utcnow)) + ), + "validUntil": cbor2.dumps( + cbor2.CBORTag(0, self.format_datetime_repr(exp)) + ), + }, + } + mso = Sign1Message( + phdr={ + Algorithm: self.private_key.alg, + KID: self.private_key.kid, + 33: self.x509_cert, + }, + # TODO: x509 (cbor2.CBORTag(33)) and federation trust_chain support + # (cbor2.CBORTag(27?)) here + # 33 means x509chain standing to rfc9360 + # in both protected and unprotected for interop purpose .. for now. + uhdr={33: self.x509_cert}, + payload=cbor2.dumps(payload), + ) + mso.key = self.private_key + return mso diff --git a/mso_mdoc/mso_mdoc/v1_0/mso/verifier.py b/mso_mdoc/mso_mdoc/v1_0/mso/verifier.py new file mode 100644 index 000000000..5e9b8161e --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/mso/verifier.py @@ -0,0 +1,63 @@ +"""MsoVerifier helper class to verify a mso.""" + +import logging +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from pycose.keys import CoseKey +from pycose.messages import Sign1Message +import cryptography +import cbor2 + +LOGGER = logging.getLogger(__name__) + + +class MsoVerifier: + """MsoVerifier helper class to verify a mso.""" + + def __init__(self, data: cbor2.CBORTag) -> None: + """Create a new MsoParser instance.""" + if isinstance(data, list): + data = cbor2.dumps(cbor2.CBORTag(18, value=data)) + + self.object: Sign1Message = Sign1Message.decode(data) + self.public_key: ( + cryptography.hazmat.backends.openssl.ec._EllipticCurvePublicKey + ) = None + self.x509_certificates: list = [] + + @property + def raw_public_keys(self) -> bytes: + """Extract public key from x509 certificates.""" + _mixed_heads = list(self.object.phdr.items()) + list(self.object.uhdr.items()) + for h, v in _mixed_heads: + if h.identifier == 33: + return list(self.object.uhdr.values()) + + def attest_public_key(self) -> None: + """Asstest public key.""" + LOGGER.warning( + "TODO: in next releases. " + "The certificate is to be considered as untrusted, this release " + "doesn't validate x.509 certificate chain. See next releases and " + "python certvalidator or cryptography for that." + ) + + def load_public_key(self) -> None: + """Load the public key from the x509 certificate.""" + self.attest_public_key() + + for i in self.raw_public_keys: + self.x509_certificates.append( + cryptography.x509.load_der_x509_certificate(i) + ) + + self.public_key = self.x509_certificates[0].public_key() + pem_public = self.public_key.public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo + ).decode() + self.object.key = CoseKey.from_pem_public_key(pem_public) + + def verify_signature(self) -> bool: + """Verify the signature.""" + self.load_public_key() + + return self.object.verify_signature() diff --git a/mso_mdoc/mso_mdoc/v1_0/routes.py b/mso_mdoc/mso_mdoc/v1_0/routes.py new file mode 100644 index 000000000..28c7da1f1 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/routes.py @@ -0,0 +1,157 @@ +"""mso_mdoc admin routes.""" + +import logging +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema +from marshmallow import fields + +from aries_cloudagent.admin.request_context import AdminRequestContext +from aries_cloudagent.messaging.jsonld.error import ( + BadJWSHeaderError, + InvalidVerificationMethod, +) +from aries_cloudagent.messaging.models.openapi import OpenAPISchema +from aries_cloudagent.messaging.valid import ( + GENERIC_DID_EXAMPLE, + GENERIC_DID_VALIDATE, + Uri, +) +from aries_cloudagent.resolver.base import ResolverError + +from .mdoc import mso_mdoc_sign, mso_mdoc_verify + +SPEC_URI = "https://www.iso.org/obp/ui/#iso:std:iso-iec:18013:-5:dis:ed-1:v1:en" +LOGGER = logging.getLogger(__name__) + + +class MdocPluginResponseSchema(OpenAPISchema): + """Response schema for mso_mdoc Plugin.""" + + +class MdocCreateSchema(OpenAPISchema): + """Request schema to create a jws with a particular DID.""" + + headers = fields.Dict() + payload = fields.Dict(required=True) + did = fields.Str( + required=False, + validate=GENERIC_DID_VALIDATE, + metadata={"description": "DID of interest", "example": GENERIC_DID_EXAMPLE}, + ) + verification_method = fields.Str( + data_key="verificationMethod", + required=False, + validate=Uri(), + metadata={ + "description": "Information used for proof verification", + "example": ( + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg34" + "2Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ), + }, + ) + + +class MdocVerifySchema(OpenAPISchema): + """Request schema to verify a mso_mdoc.""" + + mso_mdoc = fields.Str( + validate=None, metadata={"example": "a36776657273696f6e63312e..."} + ) + + +class MdocVerifyResponseSchema(OpenAPISchema): + """Response schema for mso_mdoc verification result.""" + + valid = fields.Bool(required=True) + error = fields.Str(required=False, metadata={"description": "Error text"}) + kid = fields.Str(required=True, metadata={"description": "kid of signer"}) + headers = fields.Dict( + required=True, metadata={"description": "Headers from verified mso_mdoc."} + ) + payload = fields.Dict( + required=True, metadata={"description": "Payload from verified mso_mdoc"} + ) + + +@docs( + tags=["mso_mdoc"], + summary="Creates mso_mdoc CBOR encoded binaries according to ISO 18013-5", +) +@request_schema(MdocCreateSchema) +@response_schema(MdocPluginResponseSchema(), description="") +async def mdoc_sign(request: web.BaseRequest): + """Request handler for sd-jws creation using did. + + Args: + "headers": { ... }, + "payload": { ... }, + "did": "did:example:123", + "verificationMethod": "did:example:123#keys-1" + with did and verification being mutually exclusive. + """ + context: AdminRequestContext = request["context"] + body = await request.json() + did = body.get("did") + verification_method = body.get("verificationMethod") + headers = body.get("headers", {}) + payload = body.get("payload", {}) + + try: + mso_mdoc = await mso_mdoc_sign( + context.profile, headers, payload, did, verification_method + ) + except ValueError as err: + raise web.HTTPBadRequest(reason="Bad did or verification method") from err + + return web.json_response(mso_mdoc) + + +@docs( + tags=["mso_mdoc"], + summary="Verify mso_mdoc CBOR encoded binaries according to ISO 18013-5", +) +@request_schema(MdocVerifySchema()) +@response_schema(MdocVerifyResponseSchema(), 200, description="") +async def mdoc_verify(request: web.BaseRequest): + """Request handler for mso_mdoc validation. + + Args: + "mso_mdoc": { ... } + """ + context: AdminRequestContext = request["context"] + body = await request.json() + mso_mdoc = body["mso_mdoc"] + try: + result = await mso_mdoc_verify(context.profile, mso_mdoc) + except (BadJWSHeaderError, InvalidVerificationMethod) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + except ResolverError as err: + raise web.HTTPNotFound(reason=err.roll_up) from err + + return web.json_response(result.serialize()) + + +async def register(app: web.Application): + """Register routes.""" + app.add_routes( + [ + web.post("/mso_mdoc/sign", mdoc_sign), + web.post("/mso_mdoc/verify", mdoc_verify), + ] + ) + + +def post_process_routes(app: web.Application): + """Amend swagger API.""" + + # Add top-level tags description + if "tags" not in app._state["swagger_dict"]: + app._state["swagger_dict"]["tags"] = [] + app._state["swagger_dict"]["tags"].append( + { + "name": "mso_mdoc", + "description": "mso_mdoc plugin", + "externalDocs": {"description": "Specification", "url": SPEC_URI}, + } + ) diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/__init__.py b/mso_mdoc/mso_mdoc/v1_0/tests/__init__.py new file mode 100644 index 000000000..203414061 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/__init__.py @@ -0,0 +1 @@ +"""Test cases.""" \ No newline at end of file diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/conftest.py b/mso_mdoc/mso_mdoc/v1_0/tests/conftest.py new file mode 100644 index 000000000..7f634b4aa --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/conftest.py @@ -0,0 +1,116 @@ +import pytest + +from aries_cloudagent.admin.request_context import AdminRequestContext + +from oid4vci.models.exchange import OID4VCIExchangeRecord +from oid4vci.models.supported_cred import SupportedCredential +from oid4vci.public_routes import PopResult + + +@pytest.fixture +def body(): + yield { + "format": "mso_mdoc", + "doctype": "org.iso.18013.5.1.mDL", + "proof": { + "proof_type": "jwt", + "jwt": "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IkJHVU5OTlpFSm9Cd05UU25NOW93WGVCdTBOTFJEVjR4d1llTm9kMVpxQUEiLCJ5IjoiZjlJTVhQS2xlU0FGb2tRdTc1Qlk3Nkl0QWpjVUxHWDlCeVZ0ZFVINEs0YyJ9LCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJpYXQiOjE3MjA3MzMxMTAsImV4cCI6MTcyMDczNDkxMCwiYXVkIjoiaHR0cHM6Ly9vaWQ0dmNpLnN0Zy5uZ3Jvay5pbyIsIm5vbmNlIjoiWWM4em9odk9XREFTVzh6QnA5Y1MxZyIsImlzcyI6ImRpZDprZXk6NjZhVVVobzhQdjNVaU16ZHBxdUFGVHJWa01DeEpocUJTN3BVdjFqQzhleHdFZ2FndVRNUEppa3NlV2N1U0RqYUtlMzZKanM3cnlVWnZKQVp4UGZZVUVKIn0.1ozjqUDtYzBecSEln9dANpSNBXNxEkws2ZWWaYim5B07QmlELi0nvoh3ooUUeu4Q_7ru_FXjQCIM7xgAVCrbxw", + }, + } + + +@pytest.fixture +def supported(): + yield SupportedCredential(format_data={"doctype": "org.iso.18013.5.1.mDL"}) + + +@pytest.fixture +def ex_record(): + yield OID4VCIExchangeRecord( + state=OID4VCIExchangeRecord.STATE_OFFER_CREATED, + verification_method="did:key:z6Mkn6z3Eg2mrgQmripNPGDybZYYojwZw1VPjRkCzbNV7JfN#0", + issuer_id="did:key:z6Mkn6z3Eg2mrgQmripNPGDybZYYojwZw1VPjRkCzbNV7JfN", + supported_cred_id="456", + credential_subject={"name": "alice"}, + nonce="789", + pin="000", + code="111", + token="222", + ) + + +@pytest.fixture +def pop(): + yield PopResult( + headers=None, + payload=None, + verified=True, + holder_kid="did:key:example-kid#0", + holder_jwk=None, + ) + + +@pytest.fixture +def context(): + """Test AdminRequestContext.""" + yield AdminRequestContext.test_context() + + +@pytest.fixture +def jwk(): + yield { + "kty": "OKP", + "crv": "ED25519", + "x": "cavH81X96jQL8vj3gbLQBkeE7p9cyVu8MJcC5N6lXOU=", + "d": "NsSTmfmS-D15umO64Ongi22HYcHBr7l1nl7OGurQReA", + } + + +@pytest.fixture +def did(): + yield { + "did": "did:key:z6Mkn6z3Eg2mrgQmripNPGDybZYYojwZw1VPjRkCzbNV7JfN", + "verkey": "8eizeRnLX8vJkDyfhhG8kTzYzAfiX8F33QqHAKQUC5sz", + "private_key": "NsSTmfmS-D15umO64Ongi22HYcHBr7l1nl7OGurQReA", + "public_key": "cavH81X96jQL8vj3gbLQBkeE7p9cyVu8MJcC5N6lXOU=", + } + + +@pytest.fixture +def headers(): + yield { + "doctype": "org.iso.18013.5.1.mDL", + "deviceKey": "12345678123456781234567812345678", + } + + +@pytest.fixture +def payload(): + yield { + "did": "did:key:z6Mkn6z3Eg2mrgQmripNPGDybZYYojwZw1VPjRkCzbNV7JfN", + "headers": {"deviceKey": "12345678123456781234567812345678"}, + "payload": { + "org.iso.18013.5.1": { + "expiry_date": "2029-03-31", + "issue_date": "2024-04-01", + "issuing_country": "CA", + "issuing_authority": "Ontario Ministry of Transportation", + "family_name": "Doe", + "given_name": "John", + "birth_date": "1990-03-31", + "document_number": "DJ123-45678-90123", + "un_distinguishing_sign": "CDN", + } + }, + } + + +@pytest.fixture +def issuer_auth(): + """mso.encode()""" + yield "5904c7d28459012da301270458206196787ec61cf41d9f4cfa97dc4413907e8b8ff6c55694bc5ebd07c0d9b7950318215901023081ff3081b2a003020102021412978ff28a5d42d94382c1cfdcac025b9fc49e8d300506032b65703020310b300906035504061302434e3111300f06035504030c084c6f63616c204341301e170d3234303731373033343331335a170d3234303732373033343331335a3020310b300906035504061302434e3111300f06035504030c084c6f63616c204341302a300506032b657003210071abc7f355fdea340bf2f8f781b2d0064784ee9f5cc95bbc309702e4dea55ce5300506032b657003410080fe1045fc0ef68af9c3ddf53de8934826c78fb45f4c8d82e79b1f2673bb1e485ce2e7b482be6f398497ca56d2c2e192a8f8b39b05bb21fe7aa2d61cc5655506a118215901023081ff3081b2a003020102021412978ff28a5d42d94382c1cfdcac025b9fc49e8d300506032b65703020310b300906035504061302434e3111300f06035504030c084c6f63616c204341301e170d3234303731373033343331335a170d3234303732373033343331335a3020310b300906035504061302434e3111300f06035504030c084c6f63616c204341302a300506032b657003210071abc7f355fdea340bf2f8f781b2d0064784ee9f5cc95bbc309702e4dea55ce5300506032b657003410080fe1045fc0ef68af9c3ddf53de8934826c78fb45f4c8d82e79b1f2673bb1e485ce2e7b482be6f398497ca56d2c2e192a8f8b39b05bb21fe7aa2d61cc5655506590248a66776657273696f6e63312e306f646967657374416c676f726974686d667368613235366c76616c756544696765737473a1716f72672e69736f2e31383031332e352e31a90058200f80559d7f614f73cb8feb11d6fa6889c6cb3cce2e6116f2762e6bb18fe98686015820e7c276c74760d3004bb227627cf6bafb7d8260e8cdee7dd1e7417a1e5e4565a4025820d6701ca377cfd49b16c662abba87610e458e95163093d46004de3bc072976880035820c060377bc483de60cfc5a19ef0c61b5485127af944355d1eb64617972b9cf7c604582030d6f95910e800d2849992b0eba7de32998e2de1e91036fd3498c472a583c9a2055820155c35da62e635ab1b2ba78c7eea82c93436696643efe4ec86b9854711131602065820bdec6c1e2afea89273eaed5319379e89f04f816c647cdfe0dd50128fb69802a907582016e7d7f6d2c59d30851d8b9444456500790ddda6a2d9206c0081a5cad8087637085820cb52b000d1086b14f97f760f9c3ecc73c128db19579841f12a9b7c4e865ab7736d6465766963654b6579496e666fa1696465766963654b65797820313233343536373831323334353637383132333435363738313233343536373867646f6354797065756f72672e69736f2e31383031332e352e312e6d444c6c76616c6964697479496e666fa3667369676e656456c074323032342d30372d31375430333a34333a31335a6976616c696446726f6d56c074323032342d30372d31375430333a34333a31335a6a76616c6964556e74696c56c074323032352d30372d31375430333a34333a31335a58409de675d2fd0f64de7fd4ed6900344b3e04561324b616961b61e0caeb4d39d581226ae6131c87f6713af599f20183d777e1f260b56fb0f42212bd7f188e5c760c" + + +@pytest.fixture +def mso_mdoc(): + yield "a36776657273696f6e63312e3069646f63756d656e747381a267646f6354797065756f72672e69736f2e31383031332e352e312e6d444c6c6973737565725369676e6564a26a6e616d65537061636573a1716f72672e69736f2e31383031332e352e3189d818586ea4686469676573744944006672616e646f6d582061f2f331ac88ad719976a6cc9f0940f23851a601c001430511424ceee35afbc171656c656d656e744964656e74696669657276756e5f64697374696e6775697368696e675f7369676e6c656c656d656e7456616c75656343444ed8185866a4686469676573744944016672616e646f6d582099ce495059e7e0ae8a044774a8596247d5b33a02b9d35133e2dff8b49839d88e71656c656d656e744964656e7469666965726f69737375696e675f636f756e7472796c656c656d656e7456616c7565624341d818586ca4686469676573744944026672616e646f6d5820a43e5279c96bc9864f0ee21048d8d46ef5ad553be3c8d41ef95161f736f9cc3071656c656d656e744964656e7469666965726a69737375655f646174656c656c656d656e7456616c7565d903ec6a323032342d30342d3031d8185889a4686469676573744944036672616e646f6d5820bb9f9145a1aa4d4a7a984893908ccc6e3db77b9de80db82d55c96028bc24ffa671656c656d656e744964656e7469666965727169737375696e675f617574686f726974796c656c656d656e7456616c756578224f6e746172696f204d696e6973747279206f66205472616e73706f72746174696f6ed818586ca4686469676573744944046672616e646f6d5820f4e468dd304e1ca775d3ca2398983bbad56671bc54547b38d04b61bd9d0edc6271656c656d656e744964656e7469666965726a62697274685f646174656c656c656d656e7456616c7565d903ec6a313939302d30332d3331d8185875a4686469676573744944056672616e646f6d58200b7412d206bc6e92e10bdf5f9c1b93a52d5d42c5052423bccaa595bea8e46e1a71656c656d656e744964656e7469666965726f646f63756d656e745f6e756d6265726c656c656d656e7456616c756571444a3132332d34353637382d3930313233d8185863a4686469676573744944066672616e646f6d5820c5901315a7a97b9af60e78965ce0fd0e3465e7dbb5d1f60b5ddb7f4bd1b783c871656c656d656e744964656e7469666965726b66616d696c795f6e616d656c656c656d656e7456616c756563446f65d818586da4686469676573744944076672616e646f6d582060c7538805bfee9fbdb4ece8cb1e83dbdb17b99ca6fdc51dc3806ae791e6dbb171656c656d656e744964656e7469666965726b6578706972795f646174656c656c656d656e7456616c7565d903ec6a323032392d30332d3331d8185863a4686469676573744944086672616e646f6d5820db795a0aefad87042012dbc8adb7cad0c734cf66049570666f0b42555364cb5e71656c656d656e744964656e7469666965726a676976656e5f6e616d656c656c656d656e7456616c7565644a6f686e6a697373756572417574688459012da3012704582078872c0f24908935938c69960b05bab2766904db2ac26ed9928a08d232662ab818215901023081ff3081b2a00302010202147a498062fa06687807d711a26af37ef36811d5a9300506032b65703020310b300906035504061302434e3111300f06035504030c084c6f63616c204341301e170d3234303731373031323334315a170d3234303732373031323334315a3020310b300906035504061302434e3111300f06035504030c084c6f63616c204341302a300506032b657003210071abc7f355fdea340bf2f8f781b2d0064784ee9f5cc95bbc309702e4dea55ce5300506032b65700341001538625bdd0f1ded7b80ce7aed09ec00ec666283811b58c1034f735bd6d92d68b218ad91065ce36af8eacbd8ec9cd185c0ae77620af777b27b784af0af399d0ea118215901023081ff3081b2a00302010202147a498062fa06687807d711a26af37ef36811d5a9300506032b65703020310b300906035504061302434e3111300f06035504030c084c6f63616c204341301e170d3234303731373031323334315a170d3234303732373031323334315a3020310b300906035504061302434e3111300f06035504030c084c6f63616c204341302a300506032b657003210071abc7f355fdea340bf2f8f781b2d0064784ee9f5cc95bbc309702e4dea55ce5300506032b65700341001538625bdd0f1ded7b80ce7aed09ec00ec666283811b58c1034f735bd6d92d68b218ad91065ce36af8eacbd8ec9cd185c0ae77620af777b27b784af0af399d0e59024dd818590248a66776657273696f6e63312e306f646967657374416c676f726974686d667368613235366c76616c756544696765737473a1716f72672e69736f2e31383031332e352e31a90058208cd10d0dccfa82ae19f69d9fae862bd96fe9ada4408eca7a9b0bac23aa76c35801582052095b1cc5a77eb8c1a424c9b0800b3ca928eb4199cf2d27237076aaa3c410d402582071fbf717bf874cef36cdee8c50edb686e8f9eca3618634298f1dbc99cd590094035820b493bef6da0728d971243012ab9bd8514f910c5787dd899c2eadecda7c846d3704582032aeab097b60bf5698dd31e44349a9af03c968cc28b6f9ce35812846224b2c780558203c3bde3dd6499fad865079e968fabc547666014eaa5301bbdf194774017cb0380658202a493ea48cb7b6112a75e0f97988da30e161469071f2e2537b96931352201c230758203c747db61a07c1049738ddf8d4d493920c9ff712a7cb87b6f60f9ef3734b6c100858205feb7eae0e91f2959a633de186a933beac7efdf4effb2fe02aa27724e04c15686d6465766963654b6579496e666fa1696465766963654b65797820313233343536373831323334353637383132333435363738313233343536373867646f6354797065756f72672e69736f2e31383031332e352e312e6d444c6c76616c6964697479496e666fa3667369676e656456c074323032342d30372d31375430313a32333a34315a6976616c696446726f6d56c074323032342d30372d31375430313a32333a34315a6a76616c6964556e74696c56c074323032352d30372d31375430313a32333a34315a5840de150a918590a131a9188e0a2cb49d0a7eaae28447c322441512cd7cb77a77ede5d58f21a99c7fe7199b965b7a8b94e46960d898e0a880dd492a0786fad032036673746174757300" diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/__init__.py b/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/__init__.py new file mode 100644 index 000000000..1661884d9 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/__init__.py @@ -0,0 +1 @@ +"""MDOC test cases.""" \ No newline at end of file diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_issuer.py b/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_issuer.py new file mode 100644 index 000000000..7b381ca49 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_issuer.py @@ -0,0 +1,12 @@ +import pytest + +from ...mdoc import mdoc_sign + + +@pytest.mark.asyncio +def test_mdoc_sign(jwk, headers, payload): + """Test mdoc_sign() method.""" + + mso_mdoc = mdoc_sign(jwk, headers, payload) + + assert mso_mdoc diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_verifier.py b/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_verifier.py new file mode 100644 index 000000000..405bba0ff --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/mdoc/test_verifier.py @@ -0,0 +1,12 @@ +import pytest + +from ...mdoc import mdoc_verify, MdocVerifyResult + + +@pytest.mark.asyncio +def test_mdoc_verify(mso_mdoc): + """Test mdoc_sign() method.""" + + result: MdocVerifyResult = mdoc_verify(mso_mdoc) + + assert result diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/mso/__init__.py b/mso_mdoc/mso_mdoc/v1_0/tests/mso/__init__.py new file mode 100644 index 000000000..ad6888016 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/mso/__init__.py @@ -0,0 +1 @@ +"""MSO test cases.""" \ No newline at end of file diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/mso/test_issuer.py b/mso_mdoc/mso_mdoc/v1_0/tests/mso/test_issuer.py new file mode 100644 index 000000000..7745d3004 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/mso/test_issuer.py @@ -0,0 +1,32 @@ +import os +from pycose.keys import CoseKey +from binascii import hexlify + +from aries_cloudagent.wallet.util import b64_to_bytes + +from ...mso import MsoIssuer +from ...x509 import selfsigned_x509cert + +MDOC_TYPE = "org.iso.18013.5.1.mDL" + + +def test_mso_sign(jwk, headers, payload): + """Test mso_sign() method.""" + + pk_dict = { + "KTY": jwk.get("kty") or "", # OKP, EC + "CURVE": jwk.get("crv") or "", # ED25519, P_256 + "ALG": "EdDSA" if jwk.get("kty") == "OKP" else "ES256", + "D": b64_to_bytes(jwk.get("d") or "", True), # EdDSA + "X": b64_to_bytes(jwk.get("x") or "", True), # EdDSA, EcDSA + "Y": b64_to_bytes(jwk.get("y") or "", True), # EcDSA + "KID": os.urandom(32), + } + cose_key = CoseKey.from_dict(pk_dict) + x509_cert = selfsigned_x509cert(private_key=cose_key) + + msoi = MsoIssuer(data=payload, private_key=cose_key, x509_cert=x509_cert) + mso = msoi.sign(device_key=(headers.get("deviceKey") or ""), doctype=MDOC_TYPE) + mso_signature = hexlify(mso.encode()) + + assert mso_signature diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/mso/test_verifier.py b/mso_mdoc/mso_mdoc/v1_0/tests/mso/test_verifier.py new file mode 100644 index 000000000..68f3e744a --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/mso/test_verifier.py @@ -0,0 +1,17 @@ +import pytest +import cbor2 +from binascii import unhexlify + +from ...mso import MsoVerifier + + +@pytest.mark.asyncio +async def test_mso_verify(issuer_auth): + """Test verify_signature() method.""" + + issuer_auth_bytes = unhexlify(issuer_auth) + issuer_auth_obj = cbor2.loads(issuer_auth_bytes) + mso_verifier = MsoVerifier(issuer_auth_obj) + valid = mso_verifier.verify_signature() + + assert valid diff --git a/mso_mdoc/mso_mdoc/v1_0/tests/test_x509.py b/mso_mdoc/mso_mdoc/v1_0/tests/test_x509.py new file mode 100644 index 000000000..26c00d65a --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/tests/test_x509.py @@ -0,0 +1,28 @@ +import pytest + +import os +from pycose.keys import CoseKey + +from aries_cloudagent.wallet.util import b64_to_bytes + +from ..x509 import selfsigned_x509cert + + +@pytest.mark.asyncio +def test_selfsigned_x509cert(jwk, headers, payload): + """Test selfsigned_x509cert() method.""" + + pk_dict = { + "KTY": jwk.get("kty") or "", # OKP, EC + "CURVE": jwk.get("crv") or "", # ED25519, P_256 + "ALG": "EdDSA" if jwk.get("kty") == "OKP" else "ES256", + "D": b64_to_bytes(jwk.get("d") or "", True), # EdDSA + "X": b64_to_bytes(jwk.get("x") or "", True), # EdDSA, EcDSA + "Y": b64_to_bytes(jwk.get("y") or "", True), # EcDSA + "KID": os.urandom(32), + } + cose_key = CoseKey.from_dict(pk_dict) + + x509_cert = selfsigned_x509cert(private_key=cose_key) + + assert x509_cert diff --git a/mso_mdoc/mso_mdoc/v1_0/x509.py b/mso_mdoc/mso_mdoc/v1_0/x509.py new file mode 100644 index 000000000..f194b3962 --- /dev/null +++ b/mso_mdoc/mso_mdoc/v1_0/x509.py @@ -0,0 +1,30 @@ +"""X.509 certificate utilities.""" +from datetime import datetime, timezone, timedelta +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cwt import COSEKey +from pycose.keys import CoseKey +from pycose.keys.keytype import KtyOKP + +def selfsigned_x509cert(private_key: CoseKey): + """Generate a self-signed X.509 certificate from a COSE key.""" + ckey = COSEKey.from_bytes(private_key.encode()) + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "CN"), + x509.NameAttribute(NameOID.COMMON_NAME, "Local CA"), + ] + ) + utcnow = datetime.now(timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(ckey.key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(utcnow) + .not_valid_after(utcnow + timedelta(days=10)) + .sign(ckey.key, None if private_key.kty == KtyOKP else hashes.SHA256()) + ) + return cert.public_bytes(getattr(serialization.Encoding, "DER")) diff --git a/mso_mdoc/pyproject.toml b/mso_mdoc/pyproject.toml new file mode 100644 index 000000000..fdd91ed72 --- /dev/null +++ b/mso_mdoc/pyproject.toml @@ -0,0 +1,93 @@ +[tool.poetry] +name = "mso_mdoc" +version = "0.1.0" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "^3.9" +cbor2 = "~5" +cbor-diag = "*" +cwt = "~2" +pycose = "~1" + +# Define ACA-Py as an optional/extra dependancy so it can be +# explicitly installed with the plugin if desired. +aries-cloudagent = { version = ">=0.10.3, < 1.0.0", optional = true } +oid4vci = {path = "../oid4vci", optional = true, develop = true} + +[tool.poetry.extras] +aca-py = ["aries-cloudagent"] +oid4vci = ["oid4vci"] + +[tool.poetry.dev-dependencies] +ruff = "^0.5.0" +black = "~24.4.2" +pytest = "^8.2.0" +pytest-asyncio = "~0.23.7" +pytest-cov = "^5.0.0" +pytest-ruff = "^0.3.2" +asynctest = "0.13.0" +setuptools = "^70.3.0" + +[tool.poetry.group.integration.dependencies] +aries-askar = { version = "~0.3.0" } +indy-credx = { version = "~1.1.1" } +indy-vdr = { version = "~0.4.1" } +ursa-bbs-signatures = { version = "~1.0.1" } +python3-indy = { version = "^1.11.1" } +anoncreds = { version = "0.2.0" } + +[tool.ruff] +line-length = 90 + +[tool.ruff.lint] +select = ["E", "F", "C", "D"] +ignore = [ + # Google Python Doc Style + "D203", "D204", "D213", "D215", "D400", "D401", "D404", "D406", "D407", + "D408", "D409", "D413", + "D202", # Allow blank line after docstring + "D104", # Don't require docstring in public package + # Things that we should fix, but are too much work right now + "D417", "C901", +] + +[tool.ruff.lint.per-file-ignores] +"**/{tests}/*" = ["F841", "D", "E501"] + +[tool.pytest.ini_options] +testpaths = "mso_mdoc" +addopts = """ + -p no:warnings + --quiet --junitxml=./.test-reports/junit.xml + --cov-config .coveragerc --cov=mso_mdoc --cov-report term --cov-report xml +""" +markers = [] +junit_family = "xunit1" +asyncio_mode = "auto" + +[tool.coverage.run] +omit = [ + "*/tests/*", + "docker/*", + "integration/*", + "*/definition.py" +] +data_file = ".test-reports/.coverage" + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "@abstract" +] +precision = 2 +skip_covered = true +show_missing = true + +[tool.coverage.xml] +output = ".test-reports/coverage.xml" + +[build-system] +requires = ["setuptools", "poetry-core>=1.2"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/oid4vci/.DS_Store b/oid4vci/.DS_Store deleted file mode 100644 index d7e56fa676ba7cebaed7ab71fe65ebc26cc14e0c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5T0$LO({YT3LY1{7HqMo;3d@h0!H+pQX3OAm}X0V52cWszL2lt^Ek7+ zEw<6CNS%S%Z+CVkVZMZ&9RLumao7N;0)UH3SSaK0jnF=6P72oIAPRa$Fdg=TL8Km0(Le{pVT3MsH*p%OX-kdMaHwN_v*DKAa;LgCn>B0lcwUpU zlloCjHtLPj`Mm7z9UPurc3zTRs$LC)0)I9wI~Eu4hLdG76Mv82Z>7PS`ih6`#kM zBny`A!IOxcf$TS#pCt3a=7%xHgIU~T%wvoRXowt@3PE$FYr_R2ax+H8Moh6d%BSOs zf&QWi*Dlxtn=lP4|F-@p)e-nUqH&r`vu^j3Z`A6WTMf|=EpZn<={(Ga*=!mNCpXx; z)H;nyeHdLOqkQ1)oN1K}6E&Kcgd`q8$nABaVx0$irsAo|4Xr~oMRVY_=kvpkJX&<* z{J4A2k-cv3WU*+9_TK*K#o#%8QToj?$$@ku+YU>32W6$G*KndztscQw=9Te;!~iis z3=jir#eh2oL~E_8recW!V&EqX;QkbTm zFH`x*UrwPOF+dFbGX{9QANB(%%AKt%<>6T?q1{76!MFky5YU${0novHWY;*YU!o3i YzQe*Ij)HcR4oDXPLkN9{fnQ+Y3y-Z#YybcN diff --git a/oid4vci/demo/docker-compose.yaml b/oid4vci/demo/docker-compose.yaml index cc2d835e2..065881d7b 100644 --- a/oid4vci/demo/docker-compose.yaml +++ b/oid4vci/demo/docker-compose.yaml @@ -20,6 +20,7 @@ services: TUNNEL_ENDPOINT: http://ngrok:4040 OID4VCI_HOST: 0.0.0.0 OID4VCI_PORT: 8081 + OID4VCI_CRED_HANDLER: '{"jwt_vc_json": "jwt_vc_json.v1_0"}' entrypoint: > /bin/sh -c '/entrypoint.sh aca-py "$$@"' -- command: > diff --git a/oid4vci/docker/Dockerfile b/oid4vci/docker/Dockerfile index ae305b437..fa9a6dffa 100644 --- a/oid4vci/docker/Dockerfile +++ b/oid4vci/docker/Dockerfile @@ -1,9 +1,13 @@ FROM python:3.9-slim-bullseye AS base -WORKDIR /usr/src/app - -# Install and configure poetry USER root +# Install jwt_vc_json plugin +WORKDIR /usr/src +RUN mkdir jwt_vc_json +COPY jwt_vc_json jwt_vc_json + +# Install and configure poetry +WORKDIR /usr/src/app ENV POETRY_VERSION=1.7.1 ENV POETRY_HOME=/opt/poetry RUN apt-get update && apt-get install -y curl jq && apt-get clean @@ -14,8 +18,8 @@ RUN poetry config virtualenvs.in-project true # Setup project RUN mkdir oid4vci && touch oid4vci/__init__.py -COPY pyproject.toml poetry.lock README.md ./ -RUN poetry install --without dev --extras "aca-py" +COPY oid4vci/pyproject.toml oid4vci/poetry.lock oid4vci/README.md ./ +RUN poetry install --without dev --all-extras USER $user FROM python:3.9-bullseye @@ -24,8 +28,8 @@ WORKDIR /usr/src/app COPY --from=base /usr/src/app/.venv /usr/src/app/.venv ENV PATH="/usr/src/app/.venv/bin:$PATH" RUN apt-get update && apt-get install -y curl jq && apt-get clean -COPY oid4vci/ oid4vci/ -COPY docker/*.yml ./ +COPY oid4vci/oid4vci/ oid4vci/ +COPY oid4vci/docker/*.yml ./ ENTRYPOINT ["/bin/bash", "-c", "aca-py \"$@\"", "--"] CMD ["start", "--arg-file", "default.yml"] diff --git a/oid4vci/integration/Dockerfile b/oid4vci/integration/Dockerfile index 13dab987c..8712a7a8c 100644 --- a/oid4vci/integration/Dockerfile +++ b/oid4vci/integration/Dockerfile @@ -17,5 +17,6 @@ RUN poetry install --only main COPY sphereon_wrapper/* sphereon_wrapper/ COPY credo_wrapper/* credo_wrapper/ COPY tests/* tests/ +COPY oid4vci_client/* oid4vci_client/ ENTRYPOINT ["poetry", "run", "pytest"] diff --git a/oid4vci/integration/docker-compose.yml b/oid4vci/integration/docker-compose.yml index 675b61c69..7e48dd2af 100644 --- a/oid4vci/integration/docker-compose.yml +++ b/oid4vci/integration/docker-compose.yml @@ -3,8 +3,8 @@ services: issuer: image: oid4vci build: - dockerfile: docker/Dockerfile - context: .. + dockerfile: oid4vci/docker/Dockerfile + context: ../.. ports: - "3000:3000" - "3001:3001" @@ -17,6 +17,7 @@ services: OID4VCI_HOST: 0.0.0.0 OID4VCI_PORT: 8081 OID4VCI_ENDPOINT: "http://issuer:8081" + OID4VCI_CRED_HANDLER: '{"jwt_vc_json": "jwt_vc_json.v1_0"}' command: > start --inbound-transport http 0.0.0.0 3000 @@ -52,3 +53,4 @@ services: depends_on: issuer: condition: service_healthy + \ No newline at end of file diff --git a/oid4vci/integration/poetry.lock b/oid4vci/integration/poetry.lock index 9cfa3b403..e7c85df7c 100644 --- a/oid4vci/integration/poetry.lock +++ b/oid4vci/integration/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "acapy-controller" diff --git a/oid4vci/oid4vci/.DS_Store b/oid4vci/oid4vci/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 "Config": @@ -34,6 +36,9 @@ def from_settings(cls, settings: BaseSettings) -> "Config": host = plugin_settings.get("host") or getenv("OID4VCI_HOST") port = int(plugin_settings.get("port") or getenv("OID4VCI_PORT", "0")) endpoint = plugin_settings.get("endpoint") or getenv("OID4VCI_ENDPOINT") + cred_handler = plugin_settings.get("cred_handler") or getenv( + "OID4VCI_CRED_HANDLER" + ) if not host: raise ConfigError("host", "OID4VCI_HOST") @@ -41,5 +46,9 @@ def from_settings(cls, settings: BaseSettings) -> "Config": raise ConfigError("port", "OID4VCI_PORT") if not endpoint: raise ConfigError("endpoint", "OID4VCI_ENDPOINT") + if not cred_handler: + raise ConfigError("cred_handler", "OID4VCI_CRED_HANDLER") + + cred_handler = json.loads(cred_handler) - return cls(host, port, endpoint) + return cls(host, port, endpoint, cred_handler) diff --git a/oid4vci/oid4vci/cred_processor.py b/oid4vci/oid4vci/cred_processor.py new file mode 100644 index 000000000..ba4405d78 --- /dev/null +++ b/oid4vci/oid4vci/cred_processor.py @@ -0,0 +1,39 @@ +"""CredProcessor interface and exception.""" + +from abc import ABC, abstractmethod + +from aries_cloudagent.core.error import BaseError +from aries_cloudagent.admin.request_context import AdminRequestContext + +from .models.exchange import OID4VCIExchangeRecord +from .models.supported_cred import SupportedCredential +from .pop_result import PopResult + + +class ICredProcessor(ABC): + """Returns singed credential payload.""" + + @abstractmethod + def issue_cred( + self, + body: any, + supported: SupportedCredential, + ex_record: OID4VCIExchangeRecord, + pop: PopResult, + context: AdminRequestContext, + ): + """Method signature. + + Args: + body: any + supported: SupportedCredential + ex_record: OID4VCIExchangeRecord + pop: PopResult + context: AdminRequestContext + Returns: + encoded: signed credential payload. + """ + + +class CredIssueError(BaseError): + """Base class for CredProcessor errors.""" diff --git a/oid4vci/oid4vci/models/exchange.py b/oid4vci/oid4vci/models/exchange.py index ab0620824..6bb69896b 100644 --- a/oid4vci/oid4vci/models/exchange.py +++ b/oid4vci/oid4vci/models/exchange.py @@ -20,7 +20,7 @@ class Meta: schema_class = "OID4VCIExchangeRecordSchema" RECORD_TYPE = "oid4vci" - EVENT_NAMESPACE = "oid4vci" + # EVENT_NAMESPACE = "oid4vci" RECORD_TOPIC = "oid4vci" RECORD_ID_NAME = "exchange_id" STATE_CREATED = "created" diff --git a/oid4vci/oid4vci/models/supported_cred.py b/oid4vci/oid4vci/models/supported_cred.py index 2bf1d30da..5ef3891e4 100644 --- a/oid4vci/oid4vci/models/supported_cred.py +++ b/oid4vci/oid4vci/models/supported_cred.py @@ -14,7 +14,8 @@ class Meta: schema_class = "SupportedCredentialSchema" - EVENT_NAMESPACE = "oid4vci" + #EVENT_NAMESPACE = "oid4vci" + RECORD_TOPIC = "oid4vci" RECORD_ID_NAME = "supported_cred_id" RECORD_TYPE = "supported_cred" TAG_NAMES = {"identifier", "format"} diff --git a/oid4vci/oid4vci/pop_result.py b/oid4vci/oid4vci/pop_result.py new file mode 100644 index 000000000..a26efec78 --- /dev/null +++ b/oid4vci/oid4vci/pop_result.py @@ -0,0 +1,15 @@ +"""PopResult dataclass.""" + +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Optional + + +@dataclass +class PopResult: + """Result from proof of posession.""" + + headers: Mapping[str, Any] + payload: Mapping[str, Any] + verified: bool + holder_kid: Optional[str] + holder_jwk: Optional[Dict[str, Any]] diff --git a/oid4vci/oid4vci/public_routes.py b/oid4vci/oid4vci/public_routes.py index 891a69a27..6c46e69d1 100644 --- a/oid4vci/oid4vci/public_routes.py +++ b/oid4vci/oid4vci/public_routes.py @@ -2,10 +2,8 @@ import datetime import logging -from dataclasses import dataclass from secrets import token_urlsafe -from typing import Any, Dict, List, Mapping, Optional -import uuid +from typing import Any, Dict, List, Optional import jwt from aiohttp import web @@ -17,6 +15,7 @@ from aries_cloudagent.messaging.models.openapi import OpenAPISchema from aries_cloudagent.resolver.did_resolver import DIDResolver from aries_cloudagent.storage.error import StorageError, StorageNotFoundError +from aries_cloudagent.utils.classloader import ClassLoader, ModuleLoadError from aries_cloudagent.wallet.base import WalletError from aries_cloudagent.wallet.error import WalletNotFoundError from aries_cloudagent.wallet.jwt import ( @@ -32,6 +31,8 @@ from .config import Config from .models.exchange import OID4VCIExchangeRecord from .models.supported_cred import SupportedCredential +from .pop_result import PopResult +from .cred_processor import CredIssueError LOGGER = logging.getLogger(__name__) PRE_AUTHORIZED_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:pre-authorized_code" @@ -222,17 +223,6 @@ async def key_material_for_kid(profile: Profile, kid: str): raise web.HTTPBadRequest(reason="Unsupported verification method type") -@dataclass -class PopResult: - """Result from proof of posession.""" - - headers: Mapping[str, Any] - payload: Mapping[str, Any] - verified: bool - holder_kid: Optional[str] - holder_jwk: Optional[Dict[str, Any]] - - async def handle_proof_of_posession( profile: Profile, proof: Dict[str, Any], nonce: str ): @@ -317,31 +307,28 @@ async def issue_cred(request: web.Request): supported = await SupportedCredential.retrieve_by_id( session, ex_record.supported_cred_id ) + config = Config.from_settings(context.settings) + handler_name = config.cred_handler[supported.format] except (StorageError, BaseModelError, StorageNotFoundError) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err - if supported.format != "jwt_vc_json": - raise web.HTTPUnprocessableEntity(reason="Only jwt_vc_json is supported.") - if supported.format_data is None: - LOGGER.error("No format_data for supported credential of format jwt_vc_json") - raise web.HTTPInternalServerError() + if ex_record.nonce is None: + raise web.HTTPBadRequest( + reason="Invalid exchange; no offer created for this request" + ) if supported.format != body.get("format"): raise web.HTTPBadRequest(reason="Requested format does not match offer.") - if not types_are_subset(body.get("types"), supported.format_data.get("types")): - raise web.HTTPBadRequest(reason="Requested types does not match offer.") - current_time = datetime.datetime.now(datetime.timezone.utc) - current_time_unix_timestamp = int(current_time.timestamp()) - formatted_time = current_time.strftime("%Y-%m-%dT%H:%M:%SZ") + if handler_name is None: + raise web.HTTPUnprocessableEntity(reason=f"{supported.format} is supported.") + + if supported.format_data is None: + LOGGER.error(f"No format_data for supported credential {supported.format}.") + raise web.HTTPInternalServerError() - cred_id = f"urn:uuid:{str(uuid.uuid4())}" if "proof" not in body: - raise web.HTTPBadRequest(reason="proof is required for jwt_vc_json") - if ex_record.nonce is None: - raise web.HTTPBadRequest( - reason="Invalid exchange; no offer created for this request" - ) + raise web.HTTPBadRequest(reason=f"proof is required for {supported.format}") pop = await handle_proof_of_posession( context.profile, body["proof"], ex_record.nonce @@ -349,30 +336,21 @@ async def issue_cred(request: web.Request): if not pop.verified: raise web.HTTPBadRequest(reason="Invalid proof") - if not pop.holder_kid: - raise web.HTTPBadRequest(reason="No kid in proof; required for jwt_vc_json") - - # note: Some wallets require that the "jti" and "id" are a uri - payload = { - "vc": { - **(supported.vc_additional_data or {}), - "id": cred_id, - "issuer": ex_record.issuer_id, - "issuanceDate": formatted_time, - "credentialSubject": { - **(ex_record.credential_subject or {}), - "id": pop.holder_kid, - }, - }, - "iss": ex_record.issuer_id, - "nbf": current_time_unix_timestamp, - "jti": cred_id, - "sub": pop.holder_kid, - } + try: + handler = ClassLoader.load_module(handler_name) + LOGGER.debug(f"Loaded module: {handler_name}") + except ModuleLoadError as e: + LOGGER.error(f"Error loading handler module: {e}") + raise web.HTTPInternalServerError( + reason=f"No handler to process {supported.format} credential." + ) - jws = await jwt_sign( - context.profile, {}, payload, verification_method=ex_record.verification_method - ) + try: + credential = await handler.cred_processor.issue_cred( + body, supported, ex_record, pop, context + ) + except CredIssueError as e: + raise web.HTTPBadRequest(reason=e.message) async with context.session() as session: ex_record.state = OID4VCIExchangeRecord.STATE_ISSUED @@ -384,8 +362,8 @@ async def issue_cred(request: web.Request): return web.json_response( { - "format": "jwt_vc_json", - "credential": jws, + "format": supported.format, + "credential": credential, } ) diff --git a/oid4vci/oid4vci/routes.py b/oid4vci/oid4vci/routes.py index 8438bc06b..7f36a815b 100644 --- a/oid4vci/oid4vci/routes.py +++ b/oid4vci/oid4vci/routes.py @@ -131,13 +131,11 @@ class ExchangeRecordCreateRequestSchema(OpenAPISchema): "description": "Identifier used to identify credential supported record", }, ) - credential_subject = ( - fields.Dict( - required=True, - metadata={ - "description": "desired claim and value in credential", - }, - ), + credential_subject = fields.Dict( + required=True, + metadata={ + "description": "desired claim and value in credential", + }, ) pin = fields.Str( required=False, diff --git a/oid4vci/oid4vci/tests/routes/conftest.py b/oid4vci/oid4vci/tests/routes/conftest.py index 890e844d1..45a1657a8 100644 --- a/oid4vci/oid4vci/tests/routes/conftest.py +++ b/oid4vci/oid4vci/tests/routes/conftest.py @@ -19,6 +19,7 @@ def context(): "endpoint": "http://localhost:8020", "host": "0.0.0.0", "port": 8020, + "cred_handler": '{"jwt_vc_json": "jwt_vc_json.v1_0"}', } } } diff --git a/oid4vci/poetry.lock b/oid4vci/poetry.lock index 52d29b8a7..424c68254 100644 --- a/oid4vci/poetry.lock +++ b/oid4vci/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiohttp" @@ -966,6 +966,8 @@ files = [ {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d13b4310db337f4d2103867c5a05090b22bc4d50ca842093779ef541ea9c9eea"}, {file = "frozendict-2.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:b3b967d5065872e27b06f785a80c0ed0a45d1f7c9b85223da05358e734d858ca"}, {file = "frozendict-2.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:4ae8d05c8d0b6134bfb6bfb369d5fa0c4df21eabb5ca7f645af95fdc6689678e"}, + {file = "frozendict-2.4.4-py311-none-any.whl", hash = "sha256:705efca8d74d3facbb6ace80ab3afdd28eb8a237bfb4063ed89996b024bc443d"}, + {file = "frozendict-2.4.4-py312-none-any.whl", hash = "sha256:d9647563e76adb05b7cde2172403123380871360a114f546b4ae1704510801e5"}, {file = "frozendict-2.4.4.tar.gz", hash = "sha256:3f7c031b26e4ee6a3f786ceb5e3abf1181c4ade92dce1f847da26ea2c96008c7"}, ] @@ -1179,6 +1181,23 @@ files = [ cryptography = ">=3.4" typing-extensions = ">=4.5.0" +[[package]] +name = "jwt-vc-json" +version = "0.1.0" +description = "jwt_vc_json credential handler plugin" +optional = true +python-versions = "^3.9" +files = [] +develop = false + +[package.extras] +aca-py = ["aries-cloudagent (>=0.10.3,<1.0.0)"] +oid4vci = ["oid4vci @ file:///Users/weiiv/Workspace/di/vc/aries-acapy-plugins/oid4vci"] + +[package.source] +type = "directory" +url = "../jwt_vc_json" + [[package]] name = "lxml" version = "5.2.2" @@ -2575,8 +2594,9 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [extras] aca-py = ["aries-cloudagent"] +plugins = ["jwt-vc-json"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "fa30ec89ba16b9795e52cd65b1dea6b3d8fb4275c447c90ca73cfa497a398e3b" +content-hash = "0c97649b7ae66ef8f2adb78a6a94d7a328a74d2f751422a1972c1b45bbf5d2a0" diff --git a/oid4vci/pyproject.toml b/oid4vci/pyproject.toml index 2fcb41144..c0a231f10 100644 --- a/oid4vci/pyproject.toml +++ b/oid4vci/pyproject.toml @@ -15,9 +15,12 @@ aiohttp = "^3.9.5" aries-askar = "~0.3.0" aiohttp-cors = "^0.7.0" marshmallow = "^3.20.1" +jwt-vc-json = {path = "../jwt_vc_json", optional = true} [tool.poetry.extras] aca-py = ["aries-cloudagent"] +# Credential format handler plugins +plugins = ["jwt-vc-json"] [tool.poetry.dev-dependencies] ruff = "^0.5.4" @@ -50,7 +53,7 @@ ignore = [ "D417", "C901", ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "**/{tests}/*" = ["F841", "D", "E501"] [tool.pytest.ini_options]