├── .github └── workflows │ ├── pypi.yml │ └── python-package.yml ├── .gitignore ├── .vscode └── settings.json ├── CODEOWNERS ├── LICENSE ├── README.md ├── examples ├── address_only_flat │ └── specification.yml ├── address_only_recursive │ └── specification.yml ├── address_only_structured │ └── specification.yml ├── address_only_structured_one_open │ └── specification.yml ├── complex_eidas │ └── specification.yml ├── complex_eidas_proposal │ └── specification.yml ├── complex_ekyc │ └── specification.yml ├── json_serialization_flattened │ └── specification.yml ├── json_serialization_general │ └── specification.yml ├── jsonld │ └── specification.yml ├── settings.yml ├── simple │ └── specification.yml ├── simple_array │ └── specification.yml ├── simple_structured │ └── specification.yml ├── w3c-vc │ └── specification.yml └── w3c-vc_for_slide_deck │ └── specification.yml ├── pyproject.toml ├── src └── sd_jwt │ ├── __init__.py │ ├── bin │ ├── __init__.py │ ├── demo.py │ └── generate.py │ ├── common.py │ ├── disclosure.py │ ├── holder.py │ ├── issuer.py │ ├── utils │ ├── __init__.py │ ├── demo_settings.yml │ ├── demo_utils.py │ ├── formatting.py │ └── yaml_specification.py │ └── verifier.py └── tests ├── conftest.py ├── test_disclose_all_shortcut.py ├── test_e2e_testcases.py ├── test_scripts.py ├── test_utils_yaml_specification.py └── testcases ├── array_data_types └── specification.yml ├── array_full_sd └── specification.yml ├── array_in_sd └── specification.yml ├── array_nested_in_plain └── specification.yml ├── array_none_disclosed └── specification.yml ├── array_of_nulls └── specification.yml ├── array_of_objects └── specification.yml ├── array_of_scalars └── specification.yml ├── array_recursive_sd └── specification.yml ├── array_recursive_sd_some_disclosed └── specification.yml ├── header_mod └── specification.yml ├── json_serialization_flattened └── specification.yml ├── json_serialization_general └── specification.yml ├── key_binding └── specification.yml ├── no_sd └── specification.yml ├── object_data_types └── specification.yml ├── recursions └── specification.yml └── settings.yml /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Publish Python distribution to PyPI 3 | 4 | on: 5 | workflow_dispatch: 6 | release: 7 | types: 8 | - published 9 | 10 | jobs: 11 | build: 12 | name: Publish Python distribution to PyPI 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/sd-jwt 17 | permissions: 18 | id-token: write # mandatory for trusted publishing 19 | steps: 20 | - uses: actions/checkout@master 21 | - name: Setup Python 3.10 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: "3.10" 25 | - name: Install pypa/build 26 | run: >- 27 | python -m 28 | pip install 29 | build 30 | --user 31 | - name: Build a binary wheel and a source tarball 32 | run: >- 33 | python -m 34 | build 35 | --sdist 36 | --wheel 37 | --outdir dist/ 38 | . 39 | - name: Publish package distributions to PyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python Lint & Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest poetry 31 | python -m pip install . 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | 163 | # Ignore output of test cases except for specification.yml 164 | 165 | tests/testcases/*/* 166 | !tests/testcases/*/specification.yml 167 | 168 | # Ignore output of examples except for specification.yml 169 | examples/*/* 170 | !examples/*/specification.yml 171 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter" 4 | }, 5 | "python.formatting.provider": "none", 6 | "yaml.customTags": [ 7 | "!sd scalar", 8 | "!sd mapping", 9 | "!sd sequence" 10 | ], 11 | "python.testing.pytestArgs": [ 12 | "tests" 13 | ], 14 | "python.testing.unittestEnabled": false, 15 | "python.testing.pytestEnabled": true 16 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @danielfett 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SD-JWT Reference Implementation 2 | 3 | This is the reference implementation of the [IETF SD-JWT specification](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/) written in Python. 4 | 5 | This implementation is used to generate the examples in the IETF SD-JWT specification and it can also be used in other projects for implementing SD-JWT. 6 | 7 | ## Setup 8 | 9 | To install this implementation, make sure that `python3` and `pip` (or `pip3`) are available on your system and run the following command: 10 | 11 | ```bash 12 | # create a virtual environment to install the dependencies 13 | python3 -m venv venv 14 | source venv/bin/activate 15 | 16 | # install the latest version from git 17 | pip install git+https://github.com/openwallet-foundation-labs/sd-jwt-python.git 18 | ``` 19 | 20 | This will install the `sdjwt` python package and the `sd-jwt-generate` script. 21 | 22 | If you want to access the scripts in a new shell, it is required to activate the virtual environment: 23 | 24 | ```bash 25 | source venv/bin/activate 26 | ``` 27 | 28 | ## sd-jwt-generate 29 | 30 | The script `sd-jwt-generate` is useful for generating test cases, as they might be used for doing interoperability tests with other SD-JWT implementations, and for generating examples in the SD-JWT specification and other documents. 31 | 32 | For both use cases, the script expects a JSON file with settings (`settings.yml`). Examples for these files can be found in the [tests/testcases](tests/testcases) and [examples](examples) directories. 33 | 34 | Furthermore, the script expects, in its working directory, one subdirectory for each test case or example. In each such directory, there must be a file `specification.yml` with the test case or example specifications. Examples for these files can be found in the subdirectories of the [tests/testcases](tests/testcases) and [examples](examples) directories, respectively. 35 | 36 | The script outputs the following files in each test case or example directory: 37 | * `sd_jwt_issuance.txt`: The issued SD-JWT. (*) 38 | * `sd_jwt_presentation.txt`: The presented SD-JWT. (*) 39 | * `disclosures.md`: The disclosures, formatted as markdown (only in 'example' mode). 40 | * `user_claims.json`: The user claims. 41 | * `sd_jwt_payload.json`: The payload of the SD-JWT. 42 | * `sd_jwt_jws_part.txt`: The serialized JWS component of the SD-JWT. (*) 43 | * `kb_jwt_payload.json`: The payload of the key binding JWT. 44 | * `kb_jwt_serialized.txt`: The serialized key binding JWT. 45 | * `verified_contents.json`: The verified contents of the SD-JWT. 46 | 47 | (*) Note: When JWS JSON Serialization is used, the file extensions of these files are `.json` instead of `.txt`. 48 | 49 | To run the script, enter the respective directory and execute `sd-jwt-generate`: 50 | 51 | ```bash 52 | cd tests/testcases 53 | sd-jwt-generate example 54 | ``` 55 | 56 | ## specification.yml for Test Cases and Examples 57 | 58 | The `specification.yml` file contains the test case or example specifications. 59 | For examples, the file contains the 'input user data' (i.e., the payload that is 60 | turned into an SD-JWT) and the holder disclosed claims (i.e., a description of 61 | what data the holder wants to release). For test cases, an additional third 62 | property is contained, which is the expected output of the verifier. 63 | 64 | Implementers of SD-JWT libraries are advised to run at least the following tests: 65 | 66 | - End-to-end: The issuer creates an SD-JWT according to the input data, the 67 | holder discloses the claims according to the holder disclosed claims, and 68 | the verifier verifies the SD-JWT and outputs the expected verified contents. 69 | The test passes if the output of the verifier matches the expected verified 70 | contents. 71 | - Issuer-direct-to-holder: The issuer creates an SD-JWT according to the input 72 | data and the whole SD-JWT is put directly into the Verifier for consumption. 73 | (Note that this is possible because an SD-JWT presentation differs only by 74 | one '~' character from the SD-JWT issued by the issuer if key binding is 75 | not enforced. This character can easily be added in the test execution.) 76 | This test simulates that a holder releases all data contained in the SD-JWT 77 | and is useful to verify that the Issuer put all data into the SD-JWT in a 78 | correct way. The test passes if the output of the verifier matches the input 79 | user claims (including all claims marked for selective disclosure). 80 | 81 | In this library, the two tests are implemented in 82 | [tests/test_e2e_testcases.py](tests/test_e2e_testcases.py) and 83 | [tests/test_disclose_all_shortcut.py](tests/test_disclose_all_shortcut.py), 84 | respectively. 85 | 86 | The `specification.yml` file has the following format for test cases (find more examples in [tests/testcases](tests/testcases)): 87 | 88 | ### Input data: `user_claims` 89 | 90 | `user_claims` is a YAML dictionary with the user claims, i.e., the payload that 91 | is to be turned into an SD-JWT. **Object keys** and **array elements** (and only 92 | those!) can be marked for selective disclosure at any level in the data by 93 | applying the YAML tag "!sd" to them. 94 | 95 | This is an example of an object where two out of three keys are marked for selective disclosure: 96 | 97 | ```yaml 98 | user_claims: 99 | is_over: 100 | "13": True # not selectively disclosable - always visible to the verifier 101 | !sd "18": False # selectively disclosable 102 | !sd "21": False # selectively disclosable 103 | ``` 104 | 105 | The following shows an array with two elements, where both are marked for selective disclosure: 106 | 107 | ```yaml 108 | user_claims: 109 | nationalities: 110 | - !sd "DE" 111 | - !sd "US" 112 | ``` 113 | 114 | The following shows an array with two elements that are both objects, one of which is marked for selective disclosure: 115 | 116 | ```yaml 117 | user_claims: 118 | addresses: 119 | - street: "123 Main St" 120 | city: "Anytown" 121 | state: "NY" 122 | zip: "12345" 123 | type: "main_address" 124 | 125 | - !sd 126 | street: "456 Main St" 127 | city: "Anytown" 128 | state: "NY" 129 | zip: "12345" 130 | type: "secondary_address" 131 | ``` 132 | 133 | The following shows an object that has only one claim (`sd_array`) which is marked for selective disclosure. Note that within the array, there is no selective disclosure. 134 | 135 | ```yaml 136 | user_claims: 137 | !sd sd_array: 138 | - 32 139 | - 23 140 | ``` 141 | 142 | ### Holder Behavior: `holder_disclosed_claims` 143 | 144 | `holder_disclosed_claims` is a YAML dictionary with the claims that the holder 145 | discloses to the verifier. The structure must follow the structure of 146 | `user_claims`, but elements can be omitted. The following rules apply: 147 | 148 | - For scalar values (strings, numbers, booleans, null), the value must be 149 | `True` or `yes` if the claim is disclosed and `False` or `no` if the claim 150 | should not be disclosed. 151 | - Arrays mirror the elements of the same array in `user_claims`. For each 152 | value, if it is not `False` or `no`, the value is disclosed. If an array 153 | element in `user_claims` is an object or array, an object or array can be 154 | provided here as well to describe which elements of that object/array should 155 | be disclosed or not, if applicable. 156 | - For objects, list all keys that are to be disclosed, using a value that is 157 | not `False` or `no`. As above, if the value is an object or array, it is used 158 | to describe which elements of that object/array should be disclosed or not, 159 | if applicable. 160 | 161 | ### Verifier Output: `expect_verified_user_claims` 162 | 163 | Finally, `expect_verified_user_claims` describes what the verifier is expected 164 | to output after successfully consuming the presentation from the holder. In 165 | other words, after applying `holder_disclosed_claims` to `user_claims`, the 166 | result is `expect_verified_user_claims`. 167 | 168 | ### Other Properties 169 | 170 | 171 | When `key_binding` is set to `true`, a Key Binding JWT will be generated. 172 | 173 | Using `serialization_format`, the serialization format of the SD-JWT can be 174 | specified. The default is `compact`, but `json` is also supported. 175 | -------------------------------------------------------------------------------- /examples/address_only_flat/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: 6c5c0a49-b589-431d-bae7-219122a9ec2c 3 | !sd address: 4 | street_address: Schulstr. 12 5 | locality: Schulpforta 6 | region: Sachsen-Anhalt 7 | country: DE 8 | 9 | holder_disclosed_claims: { "address": { "street_address": true } } 10 | 11 | key_binding: False 12 | -------------------------------------------------------------------------------- /examples/address_only_recursive/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: 6c5c0a49-b589-431d-bae7-219122a9ec2c 3 | !sd address: 4 | !sd street_address: Schulstr. 12 5 | !sd locality: Schulpforta 6 | !sd region: Sachsen-Anhalt 7 | !sd country: DE 8 | 9 | holder_disclosed_claims: 10 | {} 11 | 12 | key_binding: False 13 | -------------------------------------------------------------------------------- /examples/address_only_structured/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: 6c5c0a49-b589-431d-bae7-219122a9ec2c 3 | address: 4 | !sd street_address: Schulstr. 12 5 | !sd locality: Schulpforta 6 | !sd region: Sachsen-Anhalt 7 | !sd country: DE 8 | 9 | holder_disclosed_claims: {} 10 | 11 | key_binding: False 12 | -------------------------------------------------------------------------------- /examples/address_only_structured_one_open/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: 6c5c0a49-b589-431d-bae7-219122a9ec2c 3 | address: 4 | !sd street_address: Schulstr. 12 5 | !sd locality: Schulpforta 6 | !sd region: Sachsen-Anhalt 7 | country: DE 8 | 9 | holder_disclosed_claims: {} 10 | 11 | key_binding: False 12 | -------------------------------------------------------------------------------- /examples/complex_eidas/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | verified_claims: 3 | verification: 4 | trust_framework: eidas 5 | assurance_level: high 6 | !sd evidence: 7 | - type: document 8 | time: "2022-04-22T11:30Z" 9 | !sd document: 10 | type: idcard 11 | !sd issuer: 12 | name: c_d612 13 | country: IT 14 | number: "154554" 15 | date_of_issuance: "2021-03-23" 16 | date_of_expiry: "2031-03-22" 17 | claims: 18 | person_unique_identifier: TINIT-fc0d9684-1bf0-4220-9642-8fe652c8c040 19 | given_name: Raffaello 20 | family_name: Mascetti 21 | !sd date_of_birth: "1922-03-13" 22 | !sd gender: M 23 | !sd place_of_birth: 24 | country: IT 25 | locality: Firenze 26 | !sd nationalities: 27 | - IT 28 | birth_middle_name: Lello 29 | 30 | holder_disclosed_claims: 31 | { 32 | "verified_claims": 33 | { 34 | "verification": { "evidence": [] }, 35 | "claims": { "gender": null, "place_of_birth": { "country": null } }, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /examples/complex_eidas_proposal/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | verified_claims: 3 | verification: 4 | trust_framework: eidas 5 | assurance_level: high 6 | !sd evidence: 7 | - type: electronic_record 8 | record: 9 | type: eu.europa.ec.eudiw.pid.1 10 | !sd source: 11 | organization_name: Comune di Firenze 12 | organization_id: c_d612 # italian ipa code, public service id. 13 | country_code: IT 14 | country: Italy 15 | personal_number: 9642-8fe652c8c040 # european personal id 16 | claims: 17 | !sd person_unique_identifier: TINIT-fc0d9684-1bf0-4220-9642-8fe652c8c040 18 | !sd given_name: Raffaello 19 | !sd family_name: Mascetti 20 | !sd date_of_birth: "1922-03-13" 21 | !sd gender: M 22 | !sd place_of_birth: 23 | country: IT 24 | locality: Firenze 25 | !sd nationalities: 26 | - IT 27 | birth_middle_name: Lello # other optional claim not covered by eu.europa.ec.eudiw.pid.1 28 | 29 | holder_disclosed_claims: 30 | { 31 | "verified_claims": 32 | { 33 | "verification": { "evidence": [{ "record": { "source": {} } }] }, 34 | "claims": 35 | { 36 | "person_unique_identifier": null, 37 | "given_name": null, 38 | "family_name": null, 39 | "nationalities": null, 40 | "gender": null, 41 | "place_of_birth": {}, 42 | }, 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /examples/complex_ekyc/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | verified_claims: 3 | verification: 4 | trust_framework: de_aml 5 | !sd time: "2012-04-23T18:25Z" 6 | !sd verification_process: f24c6f-6d3f-4ec5-973e-b0d8506f3bc7 7 | evidence: 8 | - !sd type: document 9 | !sd method: pipp 10 | !sd time: "2012-04-22T11:30Z" 11 | !sd document: 12 | type: idcard 13 | issuer: 14 | name: Stadt Augsburg 15 | country: DE 16 | number: "53554554" 17 | date_of_issuance: "2010-03-23" 18 | date_of_expiry: "2020-03-22" 19 | claims: 20 | !sd given_name: Max 21 | !sd family_name: Müller 22 | !sd nationalities: 23 | - DE 24 | !sd birthdate: "1956-01-28" 25 | !sd place_of_birth: 26 | country: IS 27 | locality: Þykkvabæjarklaustur 28 | !sd address: 29 | locality: Maxstadt 30 | postal_code: "12344" 31 | country: DE 32 | street_address: Weidenstraße 22 33 | !sd birth_middle_name: Timotheus 34 | !sd salutation: Dr. 35 | !sd msisdn: "49123456789" 36 | 37 | holder_disclosed_claims: 38 | { 39 | "verified_claims": 40 | { 41 | "verification": 42 | { 43 | "time": null, 44 | "evidence": [{ "method": null }], 45 | }, 46 | "claims": { "given_name": null, "family_name": null, "address": {} }, 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /examples/json_serialization_flattened/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: john_doe_42 3 | 4 | # Make only first two elements SD - this array will have three elements in the resulting SD-JWT, first two hidden 5 | nationalities: 6 | - !sd "US" 7 | - !sd "CA" 8 | - "DE" 9 | 10 | # Make all elements SD 11 | is_over: 12 | !sd "13": True 13 | !sd "18": False 14 | !sd "21": False 15 | 16 | # Make only last element SD 17 | addresses: 18 | - { street: "123 Main St", city: "Anytown", state: "NY", zip: "12345", "type": "main_address" } 19 | - !sd { street: "456 Main St", city: "Anytown", state: "NY", zip: "12345", "type": "secondary_address" } 20 | 21 | # Test what happens with existing null values that are being SD'd 22 | null_values: 23 | - null 24 | - !sd null 25 | - !sd null 26 | - null 27 | 28 | data_types: 29 | - !sd null 30 | - !sd 42 31 | - !sd 3.14 32 | - !sd "foo" 33 | - !sd True 34 | - !sd ["Test"] 35 | - !sd {"foo": "bar"} 36 | 37 | # What happens with SD arrays in SD arrays? 38 | nested_array: [[!sd "foo", !sd "bar"], [!sd "baz", !sd "qux"]] 39 | 40 | array_with_recursive_sd: 41 | - boring 42 | - !sd 43 | foo: "bar" 44 | !sd baz: 45 | qux: "quux" 46 | - [!sd "foo", !sd "bar"] 47 | 48 | !sd sd_array: 49 | - 32 50 | - 23 51 | 52 | holder_disclosed_claims: 53 | sub: null 54 | nationalities: 55 | - False 56 | - True 57 | 58 | is_over: 59 | "13": False 60 | "18": True 61 | "21": False 62 | 63 | addresses: [] 64 | 65 | data_types: 66 | - True 67 | - True 68 | - True 69 | - True 70 | - True 71 | - True 72 | - True 73 | 74 | nested_array: [[True, False], [False, True]] 75 | 76 | 77 | key_binding: True 78 | 79 | serialization_format: json 80 | -------------------------------------------------------------------------------- /examples/json_serialization_general/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: john_doe_42 3 | !sd given_name: John 4 | !sd family_name: Doe 5 | !sd birthdate: "1940-01-01" 6 | 7 | holder_disclosed_claims: 8 | sub: null 9 | given_name: true 10 | family_name: true 11 | birthdate: false 12 | 13 | key_binding: True 14 | 15 | serialization_format: json 16 | 17 | settings_override: 18 | key_settings: 19 | key_size: 256 20 | kty: EC 21 | issuer_keys: 22 | - kty: EC 23 | d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g 24 | crv: P-256 25 | x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ 26 | y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 27 | kid: issuer-key-1 28 | - kty: EC 29 | crv: P-256 30 | d: WsGosxrp0XK7VEviPL9xBm3fBb7Xys2vLhPGhESNoXY 31 | x: bN-hp3IN0GZB3OlaQnHDPhY4nZsZbQyo4wY-y1NWCvA 32 | y: vaSsH5jt9zt3aQvTvrSaFYLyjPG9Ug-2vntoNXlCbVU 33 | kid: issuer-key-2 34 | 35 | holder_key: 36 | kty: EC 37 | d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I 38 | crv: P-256 39 | x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc 40 | y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ 41 | -------------------------------------------------------------------------------- /examples/jsonld/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | { 3 | "@context": 4 | [ 5 | "https://www.w3.org/2018/credentials/v1", 6 | "https://w3id.org/vaccination/v1" 7 | ], 8 | "type": ["VerifiableCredential", "VaccinationCertificate"], 9 | "issuer": "https://example.com/issuer", 10 | "issuanceDate": "2023-02-09T11:01:59Z", 11 | "expirationDate": "2028-02-08T11:01:59Z", 12 | "name": "COVID-19 Vaccination Certificate", 13 | "description": "COVID-19 Vaccination Certificate", 14 | "credentialSubject": 15 | { 16 | "vaccine": 17 | { 18 | "type": "Vaccine", 19 | "atcCode": "J07BX03", 20 | "medicinalProductName": "COVID-19 Vaccine Moderna", 21 | "marketingAuthorizationHolder": "Moderna Biotech" 22 | }, 23 | "nextVaccinationDate": "2021-08-16T13:40:12Z", 24 | "countryOfVaccination": "GE", 25 | "dateOfVaccination": "2021-06-23T13:40:12Z", 26 | "order": "3/3", 27 | "recipient": 28 | { 29 | "type": "VaccineRecipient", 30 | "gender": "Female", 31 | "birthDate": "1961-08-17", 32 | "givenName": "Marion", 33 | "familyName": "Mustermann" 34 | }, 35 | "type": "VaccinationEvent", 36 | "administeringCentre": "Praxis Sommergarten", 37 | "batchNumber": "1626382736", 38 | "healthProfessional": "883110000015376", 39 | } 40 | } 41 | 42 | non_sd_claims: 43 | { 44 | "@context": true, 45 | "type": true, 46 | "issuanceDate": true, 47 | "expirationDate": true, 48 | "issuer": true, 49 | "name": true, 50 | "description": true, 51 | "credentialSubject": 52 | { 53 | "type": true, 54 | "recipient": { "type": true }, 55 | "vaccine": { "type": true }, 56 | }, 57 | } 58 | 59 | holder_disclosed_claims: 60 | { 61 | "credentialSubject": 62 | { 63 | "type": true, 64 | "dateOfVaccination": true, 65 | "order": true, 66 | "vaccine": 67 | { "type": true, "atcCode": true, "medicinalProductName": true }, 68 | }, 69 | } 70 | 71 | key_binding: true 72 | -------------------------------------------------------------------------------- /examples/settings.yml: -------------------------------------------------------------------------------- 1 | identifiers: 2 | issuer: "https://example.com/issuer" 3 | verifier: "https://example.com/verifier" 4 | 5 | key_settings: 6 | key_size: 256 7 | 8 | kty: EC 9 | 10 | issuer_keys: 11 | - kty: EC 12 | d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g 13 | crv: P-256 14 | x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ 15 | y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 16 | 17 | holder_key: 18 | kty: EC 19 | d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I 20 | crv: P-256 21 | x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc 22 | y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ 23 | 24 | key_binding_nonce: "1234567890" 25 | 26 | expiry_seconds: 86400000 # 1000 days 27 | 28 | random_seed: 0 29 | 30 | iat: 1683000000 # Tue May 02 2023 04:00:00 GMT+0000 31 | exp: 1883000000 # Sat Sep 01 2029 23:33:20 GMT+0000 32 | -------------------------------------------------------------------------------- /examples/simple/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sub: john_doe_42 3 | !sd given_name: John 4 | !sd family_name: Doe 5 | !sd email: johndoe@example.com 6 | !sd phone_number: +1-202-555-0101 7 | !sd address: 8 | street_address: 123 Main St 9 | locality: Anytown 10 | region: Anystate 11 | country: US 12 | !sd birthdate: "1940-01-01" 13 | 14 | holder_disclosed_claims: 15 | { "given_name": null, "family_name": null, "address": {} } 16 | 17 | key_binding: True 18 | -------------------------------------------------------------------------------- /examples/simple_array/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | sub: john_doe_42 3 | 4 | # Make only first two elements SD - this array will have three elements in the resulting SD-JWT, first two hidden 5 | nationalities: 6 | - !sd "US" 7 | - !sd "CA" 8 | - "DE" 9 | 10 | # Make all elements SD 11 | is_over: 12 | !sd "13": True 13 | !sd "18": False 14 | !sd "21": False 15 | 16 | # Make only last element SD 17 | addresses: 18 | - { 19 | street: "123 Main St", 20 | city: "Anytown", 21 | state: "NY", 22 | zip: "12345", 23 | "type": "main_address", 24 | } 25 | - !sd { 26 | street: "456 Main St", 27 | city: "Anytown", 28 | state: "NY", 29 | zip: "12345", 30 | "type": "secondary_address", 31 | } 32 | 33 | # Test what happens with existing null values that are being SD'd 34 | null_values: 35 | - null 36 | - !sd null 37 | - !sd null 38 | - null 39 | 40 | data_types: 41 | - !sd null 42 | - !sd 42 43 | - !sd 3.14 44 | - !sd "foo" 45 | - !sd True 46 | - !sd ["Test"] 47 | - !sd { "foo": "bar" } 48 | 49 | # What happens with SD arrays in SD arrays? 50 | nested_array: [[!sd "foo", !sd "bar"], [!sd "baz", !sd "qux"]] 51 | 52 | array_with_recursive_sd: 53 | - boring 54 | - !sd 55 | foo: "bar" 56 | !sd baz: 57 | qux: "quux" 58 | - [!sd "foo", !sd "bar"] 59 | 60 | !sd sd_array: 61 | - 32 62 | - 23 63 | 64 | holder_disclosed_claims: 65 | sub: null 66 | nationalities: 67 | - False 68 | - True 69 | 70 | is_over: 71 | "13": False 72 | "18": True 73 | "21": False 74 | 75 | addresses: [] 76 | 77 | data_types: 78 | - True 79 | - True 80 | - True 81 | - True 82 | - True 83 | - True 84 | - True 85 | 86 | nested_array: [[True, False], [False, True]] 87 | 88 | key_binding: True 89 | -------------------------------------------------------------------------------- /examples/simple_structured/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sub: 6c5c0a49-b589-431d-bae7-219122a9ec2c 3 | !sd given_name: 太郎 4 | !sd family_name: 山田 5 | !sd email: '"unusual email address"@example.jp' 6 | !sd phone_number: +81-80-1234-5678 7 | address: 8 | !sd street_address: 東京都港区芝公園4丁目2−8 9 | !sd locality: 東京都 10 | !sd region: 港区 11 | !sd country: JP 12 | !sd birthdate: "1940-01-01" 13 | 14 | holder_disclosed_claims: 15 | { 16 | "address": { 17 | "region": null, 18 | "country": null 19 | }, 20 | } 21 | 22 | add_decoy_claims: true 23 | key_binding: False 24 | 25 | -------------------------------------------------------------------------------- /examples/w3c-vc/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | iss: 'https://example.com' 3 | jti: 'http://example.com/credentials/3732' 4 | nbf: 1541493724 5 | iat: 1541493724 6 | cnf: 7 | jwk: 8 | kty: RSA 9 | 'n': >- 10 | 0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw 11 | e: AQAB 12 | type: IdentityCredential 13 | credentialSubject: 14 | !sd given_name: John 15 | !sd family_name: Doe 16 | !sd email: johndoe@example.com 17 | !sd phone_number: +1-202-555-0101 18 | !sd address: 19 | street_address: 123 Main St 20 | locality: Anytown 21 | region: Anystate 22 | country: US 23 | !sd birthdate: '1940-01-01' 24 | !sd is_over_18: true 25 | !sd is_over_21: true 26 | !sd is_over_65: true 27 | 28 | holder_disclosed_claims: {} 29 | -------------------------------------------------------------------------------- /examples/w3c-vc_for_slide_deck/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | iss: "https://example.com" 3 | cnf: 4 | jwk: 5 | kty: RSA 6 | "n": 0vx7agoebGcQSu....-csFCur-kEgU8awapJzKnqDKgw 7 | e: AQAB 8 | type: IdentityCredential 9 | credentialSubject: 10 | !sd given_name: Max 11 | !sd family_name: Mustermann 12 | !sd email: mustermann@example.com 13 | !sd address: 14 | street_address: Musterstr. 23 15 | locality: Berlin 16 | country: DE 17 | !sd birthdate: "1971-12-23" 18 | !sd is_over_18: true 19 | !sd is_over_21: true 20 | !sd is_over_65: false 21 | 22 | holder_disclosed_claims: 23 | { 24 | "credentialSubject": 25 | { 26 | "given_name": null, 27 | "family_name": null, 28 | "address": {}, 29 | "is_over_18": null, 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "sd-jwt" 3 | version = "0.10.4" 4 | description = "The reference implementation of the IETF SD-JWT specification." 5 | authors = ["Daniel Fett "] 6 | readme = "README.md" 7 | packages = [{include = "sd_jwt", from = "src"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.8" 11 | jwcrypto = ">=1.3.1" 12 | pyyaml = ">=5.4" 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | flake8 = "^6.0.0" 16 | black = "^23.3.0" 17 | 18 | [build-system] 19 | requires = ["poetry-core"] 20 | build-backend = "poetry.core.masonry.api" 21 | 22 | [tool.poetry.scripts] 23 | sd-jwt-demo = "sd_jwt.bin.demo:run" 24 | sd-jwt-generate = "sd_jwt.bin.generate:run" 25 | 26 | [tool.pytest.ini_options] 27 | addopts = [ 28 | "--import-mode=importlib", 29 | ] 30 | pythonpath = ["src"] 31 | -------------------------------------------------------------------------------- /src/sd_jwt/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.10.4" 2 | -------------------------------------------------------------------------------- /src/sd_jwt/bin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation-labs/sd-jwt-python/cde613902c4e23ae7160aa30966a6a5e7c658535/src/sd_jwt/bin/__init__.py -------------------------------------------------------------------------------- /src/sd_jwt/bin/demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import logging 4 | import pathlib 5 | import random 6 | import sys 7 | import datetime 8 | 9 | 10 | from sd_jwt import __version__ 11 | from sd_jwt.utils.demo_utils import ( 12 | get_jwk, 13 | print_decoded_repr, 14 | load_yaml_settings, 15 | ) 16 | from sd_jwt.holder import SDJWTHolder 17 | from sd_jwt.issuer import SDJWTIssuer 18 | from sd_jwt.verifier import SDJWTVerifier 19 | 20 | from sd_jwt.utils.formatting import ( 21 | textwrap_json, 22 | textwrap_text, 23 | multiline_code, 24 | markdown_disclosures, 25 | EXAMPLE_SHORT_WIDTH, 26 | ) 27 | from sd_jwt.utils.yaml_specification import ( 28 | load_yaml_specification, 29 | remove_sdobj_wrappers, 30 | ) 31 | 32 | logger = logging.getLogger("sd_jwt") 33 | 34 | 35 | # Generate a 16-bit random number 36 | def generate_nonce(): 37 | return bytes(random.getrandbits(8) for _ in range(16)).hex() 38 | 39 | 40 | DEFAULT_EXP_MINS = 15 41 | 42 | 43 | def run(): 44 | parser = argparse.ArgumentParser( 45 | description=f"{__file__} demo.", 46 | epilog=f"{__file__}", 47 | formatter_class=argparse.RawTextHelpFormatter, 48 | ) 49 | parser.add_argument( 50 | "example", 51 | help=( 52 | "Yaml file containing the SD-JWT demo to process. See examples/simple.yml for an example." 53 | ), 54 | type=pathlib.Path, 55 | ) 56 | parser.add_argument( 57 | "-d", 58 | "--debug", 59 | required=False, 60 | choices=("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"), 61 | default="INFO", 62 | help="Debug level, see python logging; defaults to INFO if omitted", 63 | ) 64 | parser.add_argument( 65 | "-nr", 66 | "--no-randomness", 67 | required=False, 68 | action="store_true", 69 | default=False, 70 | help=( 71 | "For the purpose of generating static examples for the spec, this command line " 72 | "switch disables randomness. Using this in production is highly insecure!" 73 | ), 74 | ) 75 | parser.add_argument( 76 | "--nonce", 77 | required=False, 78 | type=str, 79 | default=generate_nonce(), 80 | help=("given example of a nonce: 'XZOUco1u_gEPknxS78sWWg'"), 81 | ) 82 | parser.add_argument( 83 | "--iat", required=False, type=int, help=("issued at, UTC Timestamp") 84 | ) 85 | parser.add_argument( 86 | "--exp", required=False, type=int, help=("expire at, UTC Timestamp") 87 | ) 88 | parser.add_argument( 89 | "--settings-path", 90 | required=False, 91 | type=str, 92 | help=("Path to YAML file containing keys and other settings for the demo."), 93 | default="utils/demo_settings.yml", 94 | ) 95 | parser.add_argument( 96 | "--indent", 97 | required=False, 98 | type=int, 99 | default=4, 100 | help=("json output indentation level"), 101 | ) 102 | # new option to put examples into a directory 103 | parser.add_argument( 104 | "--output-dir", 105 | required=False, 106 | type=pathlib.Path, 107 | help=( 108 | "path/to/directory - Write all the examples into separate files in this directory" 109 | ), 110 | ) 111 | parser.add_argument( 112 | "-v", 113 | "--version", 114 | required=False, 115 | action="store_true", 116 | help="Print version and exit", 117 | ) 118 | 119 | _args = parser.parse_args() 120 | logger.setLevel(_args.debug) 121 | 122 | if _args.version: 123 | sys.exit(f"{__version__}") 124 | 125 | ### Load settings 126 | settings = load_yaml_settings(_args.settings_path) 127 | ### Load example file 128 | example_identifer = _args.example.stem 129 | example = load_yaml_specification(_args.example) 130 | ### "settings_override" key in example can override settings 131 | settings.update(example.get("settings_override", {})) 132 | print(f"Settings: {settings}") 133 | 134 | # If "no randomness" is requested, we hash the file name of the example 135 | # file to use it as the random seed. This ensures that the same example 136 | # file always generates the same output, but the output between 137 | # different example files is different. 138 | if _args.no_randomness: 139 | import hashlib 140 | 141 | hash_object = hashlib.sha256(_args.example.read_bytes()) 142 | # Extract the hash as integer 143 | seed = int(hash_object.hexdigest(), 16) 144 | else: 145 | seed = None 146 | 147 | demo_keys = get_jwk(settings["key_settings"], _args.no_randomness, seed) 148 | print(f"Using keys: {demo_keys}") 149 | use_decoys = example.get("add_decoy_claims", False) 150 | serialization_format = example.get("serialization_format", "compact") 151 | 152 | ### Add default claims if necessary 153 | iat = _args.iat or int(datetime.datetime.utcnow().timestamp()) 154 | exp = _args.exp or iat + (DEFAULT_EXP_MINS * 60) 155 | claims = { 156 | "iss": settings["identifiers"]["issuer"], 157 | "iat": iat, 158 | "exp": exp, 159 | } 160 | 161 | claims.update(example["user_claims"]) 162 | 163 | ### Produce SD-JWT and SVC for selected example 164 | SDJWTIssuer.unsafe_randomness = _args.no_randomness 165 | sdjwt_at_issuer = SDJWTIssuer( 166 | claims, 167 | demo_keys["issuer_keys"], 168 | demo_keys["holder_key"] if example.get("key_binding", False) else None, 169 | add_decoy_claims=use_decoys, 170 | serialization_format=serialization_format, 171 | ) 172 | 173 | ### Produce SD-JWT-R for selected example 174 | 175 | # Note: The only input from the issuer is the combined SD-JWT and SVC! 176 | 177 | sdjwt_at_holder = SDJWTHolder( 178 | sdjwt_at_issuer.sd_jwt_issuance, 179 | serialization_format=serialization_format, 180 | ) 181 | sdjwt_at_holder.create_presentation( 182 | example["holder_disclosed_claims"], 183 | _args.nonce if example.get("key_binding", False) else None, 184 | ( 185 | settings["identifiers"]["issuer"] 186 | if example.get("key_binding", False) 187 | else None 188 | ), 189 | demo_keys["holder_key"] if example.get("key_binding", False) else None, 190 | ) 191 | 192 | ### Verify the SD-JWT using the SD-JWT-R 193 | 194 | # Define a function to check the issuer and retrieve the 195 | # matching public key 196 | def cb_get_issuer_key(issuer, header_parameters): 197 | # Do not use in production - this allows to use any issuer name for demo purposes 198 | if issuer == claims["iss"]: 199 | return demo_keys["issuer_public_keys"] 200 | else: 201 | raise Exception(f"Unknown issuer: {issuer}") 202 | 203 | # Note: The only input from the holder is the combined presentation! 204 | sdjwt_at_verifier = SDJWTVerifier( 205 | sdjwt_at_holder.sd_jwt_presentation, 206 | cb_get_issuer_key, 207 | ( 208 | settings["identifiers"]["issuer"] 209 | if example.get("key_binding", False) 210 | else None 211 | ), 212 | _args.nonce if example.get("key_binding", False) else None, 213 | serialization_format=serialization_format, 214 | ) 215 | verified = sdjwt_at_verifier.get_verified_payload() 216 | 217 | ### Done - now output everything to CLI (unless --replace-examples-in was used) 218 | 219 | _artifacts = { 220 | "user_claims": ( 221 | remove_sdobj_wrappers(example["user_claims"]), 222 | "User Claims", 223 | "json", 224 | ), 225 | "sd_jwt_payload": ( 226 | sdjwt_at_issuer.sd_jwt_payload, 227 | "Payload of the SD-JWT", 228 | "json", 229 | ), 230 | "sd_jwt_jws_part": ( 231 | sdjwt_at_issuer.serialized_sd_jwt, 232 | "Serialized SD-JWT", 233 | "txt", 234 | ), 235 | "disclosures": ( 236 | markdown_disclosures( 237 | sdjwt_at_issuer.ii_disclosures, 238 | ), 239 | "Payloads of the II-Disclosures", 240 | "md", 241 | ), 242 | "sd_jwt_issuance": ( 243 | sdjwt_at_issuer.sd_jwt_issuance, 244 | "Combined SD-JWT and Disclosures", 245 | "txt", 246 | ), 247 | "kb_jwt_payload": ( 248 | ( 249 | sdjwt_at_holder.key_binding_jwt_payload 250 | if example.get("key_binding") 251 | else None 252 | ), 253 | "Payload of the Holder Binding JWT", 254 | "json", 255 | ), 256 | "kb_jwt_serialized": ( 257 | sdjwt_at_holder.serialized_key_binding_jwt, 258 | "Serialized Holder Binding JWT", 259 | "txt", 260 | ), 261 | "sd_jwt_presentation": ( 262 | sdjwt_at_holder.sd_jwt_presentation, 263 | "Combined representation of SD-JWT and HS-Disclosures", 264 | "txt", 265 | ), 266 | "verified_contents": ( 267 | verified, 268 | "Verified released contents of the SD-JWT", 269 | "json", 270 | ), 271 | } 272 | 273 | # When decoys were used, list those as well 274 | if use_decoys: 275 | # create a list of decoy digests in markdown format 276 | decoy_digests = "" 277 | 278 | for digest in sdjwt_at_issuer.decoy_digests: 279 | decoy_digests += f" * `{digest}`\n" 280 | 281 | _artifacts["decoy_digests"] = ( 282 | decoy_digests, 283 | "Decoy Claims", 284 | "md", 285 | ) 286 | 287 | if _args.output_dir: 288 | logger.info( 289 | f"Writing all the examples into separate files in '{_args.output_dir}'." 290 | ) 291 | 292 | output_dir = _args.output_dir / example_identifer 293 | 294 | if not output_dir.exists(): 295 | output_dir.mkdir(parents=True) 296 | 297 | for key, (data, _, ftype) in _artifacts.items(): 298 | if data is None: 299 | continue 300 | 301 | if ftype == "json": 302 | out = textwrap_json(data) 303 | elif ftype == "txt": 304 | out = textwrap_text(data) 305 | else: 306 | out = data 307 | 308 | with open(output_dir / f"{key}.{ftype}", "w") as f: 309 | f.write(out) 310 | 311 | else: 312 | for key, (data, description, ftype) in _artifacts.items(): 313 | print(f"{description} ({key}):") 314 | if ftype == "json": 315 | out = textwrap_json(data) 316 | elif ftype == "txt": 317 | out = textwrap_text(data) 318 | else: 319 | out = data 320 | 321 | print(out) 322 | 323 | # Small hack to display some values in decoded form 324 | if key.startswith("serialized_"): 325 | print(" - decodes to - ") 326 | print_decoded_repr(data) 327 | 328 | sys.exit(0) 329 | 330 | 331 | if __name__ == "__main__": 332 | run() 333 | -------------------------------------------------------------------------------- /src/sd_jwt/bin/generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | This utility uses the SD-JWT library to update the static test case data in the 4 | sd_jwt/test_cases directory. It is intended to be run after changes to the 5 | library that affect the test cases. 6 | """ 7 | 8 | 9 | import argparse 10 | import logging 11 | import sys 12 | from typing import Dict 13 | from pathlib import Path 14 | 15 | from sd_jwt import __version__ 16 | from sd_jwt.holder import SDJWTHolder 17 | from sd_jwt.issuer import SDJWTIssuer 18 | from sd_jwt.utils.demo_utils import get_jwk, load_yaml_settings 19 | from sd_jwt.verifier import SDJWTVerifier 20 | 21 | from sd_jwt.utils import formatting 22 | from sd_jwt.utils.yaml_specification import ( 23 | load_yaml_specification, 24 | remove_sdobj_wrappers, 25 | ) 26 | 27 | logger = logging.getLogger("sd_jwt") 28 | 29 | # Set logging to stdout 30 | logging.basicConfig(stream=sys.stdout, level=logging.INFO) 31 | 32 | 33 | def generate_test_case_data(settings: Dict, testcase_path: Path, type: str): 34 | ### Load test case data 35 | testcase = load_yaml_specification(testcase_path) 36 | settings = { 37 | **settings, 38 | **testcase.get("settings_override", {}), 39 | } # override settings 40 | 41 | seed = settings["random_seed"] 42 | 43 | demo_keys = get_jwk(settings["key_settings"], True, seed) 44 | use_decoys = testcase.get("add_decoy_claims", False) 45 | serialization_format = testcase.get("serialization_format", "compact") 46 | include_default_claims = testcase.get("include_default_claims", True) 47 | extra_header_parameters = testcase.get("extra_header_parameters", {}) 48 | issuer_keys = demo_keys["issuer_keys"] 49 | holder_key = demo_keys["holder_key"] if testcase.get("key_binding", False) else None 50 | 51 | claims = {} 52 | if include_default_claims: 53 | claims = { 54 | "iss": settings["identifiers"]["issuer"], 55 | "iat": settings["iat"], 56 | "exp": settings["exp"], 57 | } 58 | 59 | claims.update(testcase["user_claims"]) 60 | 61 | ### Produce SD-JWT and SVC for selected example 62 | SDJWTIssuer.unsafe_randomness = True 63 | sdjwt_at_issuer = SDJWTIssuer( 64 | claims, 65 | issuer_keys, 66 | holder_key, 67 | add_decoy_claims=use_decoys, 68 | serialization_format=serialization_format, 69 | extra_header_parameters=extra_header_parameters, 70 | ) 71 | 72 | ### Produce SD-JWT-R for selected example 73 | 74 | sdjwt_at_holder = SDJWTHolder( 75 | sdjwt_at_issuer.sd_jwt_issuance, 76 | serialization_format=serialization_format, 77 | ) 78 | sdjwt_at_holder.create_presentation( 79 | testcase["holder_disclosed_claims"], 80 | settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, 81 | ( 82 | settings["identifiers"]["verifier"] 83 | if testcase.get("key_binding", False) 84 | else None 85 | ), 86 | holder_key, 87 | ) 88 | 89 | ### Verify the SD-JWT using the SD-JWT-R 90 | 91 | # Define a function to check the issuer and retrieve the 92 | # matching public key 93 | def cb_get_issuer_key(issuer, header_parameters): 94 | # Do not use in production - this allows to use any issuer name for demo purposes 95 | if issuer == claims.get("iss", None): 96 | return demo_keys["issuer_public_keys"] 97 | else: 98 | raise Exception(f"Unknown issuer: {issuer}") 99 | 100 | sdjwt_at_verifier = SDJWTVerifier( 101 | sdjwt_at_holder.sd_jwt_presentation, 102 | cb_get_issuer_key, 103 | ( 104 | settings["identifiers"]["verifier"] 105 | if testcase.get("key_binding", False) 106 | else None 107 | ), 108 | settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, 109 | serialization_format=serialization_format, 110 | ) 111 | verified = sdjwt_at_verifier.get_verified_payload() 112 | 113 | # Write the test case data to the directory of the test case 114 | 115 | _artifacts = { 116 | "user_claims": ( 117 | remove_sdobj_wrappers(testcase["user_claims"]), 118 | "User Claims", 119 | "json", 120 | ), 121 | "sd_jwt_payload": ( 122 | sdjwt_at_issuer.sd_jwt_payload, 123 | "Payload of the SD-JWT", 124 | "json", 125 | ), 126 | "sd_jwt_jws_part": ( 127 | sdjwt_at_issuer.serialized_sd_jwt, 128 | "Serialized SD-JWT", 129 | "txt" if serialization_format == "compact" else "json", 130 | ), 131 | "sd_jwt_issuance": ( 132 | sdjwt_at_issuer.sd_jwt_issuance, 133 | "Combined SD-JWT and Disclosures", 134 | "txt" if serialization_format == "compact" else "json", 135 | ), 136 | "sd_jwt_presentation": ( 137 | sdjwt_at_holder.sd_jwt_presentation, 138 | "Combined representation of SD-JWT and HS-Disclosures", 139 | "txt" if serialization_format == "compact" else "json", 140 | ), 141 | "verified_contents": ( 142 | verified, 143 | "Verified released contents of the SD-JWT", 144 | "json", 145 | ), 146 | } 147 | 148 | if testcase.get("key_binding", False): 149 | _artifacts.update( 150 | { 151 | "kb_jwt_header": ( 152 | ( 153 | sdjwt_at_holder.key_binding_jwt_header 154 | if testcase.get("key_binding") 155 | else None 156 | ), 157 | "Header of the Holder Binding JWT", 158 | "json", 159 | ), 160 | "kb_jwt_payload": ( 161 | ( 162 | sdjwt_at_holder.key_binding_jwt_payload 163 | if testcase.get("key_binding") 164 | else None 165 | ), 166 | "Payload of the Holder Binding JWT", 167 | "json", 168 | ), 169 | "kb_jwt_serialized": ( 170 | sdjwt_at_holder.serialized_key_binding_jwt, 171 | "Serialized Holder Binding JWT", 172 | "txt", 173 | ), 174 | } 175 | ) 176 | 177 | # When type is example, add info about disclosures 178 | if type == "example": 179 | _artifacts["disclosures"] = ( 180 | formatting.markdown_disclosures( 181 | sdjwt_at_issuer.ii_disclosures, 182 | ), 183 | "Payloads of the II-Disclosures", 184 | "md", 185 | ) 186 | 187 | # When decoys were used, list those as well (here as a json array) 188 | if use_decoys: 189 | if type == "example": 190 | _artifacts.update( 191 | { 192 | "decoy_digests": ( 193 | formatting.markdown_decoy_digests( 194 | sdjwt_at_issuer.decoy_digests 195 | ), 196 | "Decoy Claims", 197 | "md", 198 | ) 199 | } 200 | ) 201 | else: 202 | _artifacts.update( 203 | { 204 | "decoy_digests": ( 205 | sdjwt_at_issuer.decoy_digests, 206 | "Decoy Claims", 207 | "json", 208 | ) 209 | } 210 | ) 211 | 212 | output_dir = testcase_path.parent 213 | 214 | logger.info(f"Writing test case data to '{output_dir}'.") 215 | 216 | if not output_dir.exists(): 217 | sys.exit(f"Output directory '{output_dir}' does not exist.") 218 | 219 | formatter = ( 220 | formatting.format_for_example 221 | if type == "example" 222 | else formatting.format_for_testcase 223 | ) 224 | 225 | for key, data_item in _artifacts.items(): 226 | if data_item is None: 227 | continue 228 | 229 | logger.info(f"Writing {key} to '{output_dir / key}'.") 230 | 231 | data, _, ftype = data_item 232 | 233 | with open(output_dir / f"{key}.{ftype}", "w") as f: 234 | f.write(formatter(data, ftype)) 235 | 236 | 237 | # For all *.yml files in subdirectories of the working directory, run the test case generation 238 | def run(): 239 | # This tool must be called with either "testcase" or "example" as the first argument in order 240 | # to specify which type of output to generate. 241 | 242 | parser = argparse.ArgumentParser( 243 | description=( 244 | "Generate test cases or examples for SD-JWT library. " 245 | "Test case data is suitable for use in other SD-JWT libraries. " 246 | "Examples are formatted in a markdown-friendly way (e.g., line breaks, " 247 | "markdown formatting) for direct inclusion into the specification text." 248 | ) 249 | ) 250 | 251 | # Type is a positional argument, either testcase or example 252 | parser.add_argument( 253 | "type", 254 | choices=["testcase", "example"], 255 | help="Whether to generate test cases or examples.", 256 | ) 257 | 258 | # Optional: One or more names of directories containing test cases to generate 259 | parser.add_argument( 260 | "directories", 261 | nargs="*", 262 | help=( 263 | "One or more names of directories containing test cases to generate. " 264 | "If no directories are specified, all directories containing a file " 265 | "named 'specification.yml' respectively are processed." 266 | ), 267 | ) 268 | args = parser.parse_args() 269 | 270 | basedir = Path.cwd() 271 | settings_file = basedir / "settings.yml" 272 | 273 | if not settings_file.exists(): 274 | sys.exit(f"Settings file '{settings_file}' does not exist.") 275 | 276 | if args.directories: 277 | glob = [basedir / d / "specification.yml" for d in args.directories] 278 | else: 279 | glob = basedir.glob("*/specification.yml") 280 | 281 | # load keys and other information from test_settings.yml 282 | settings = load_yaml_settings(settings_file) 283 | 284 | for case_path in glob: 285 | logger.info(f"Generating data for '{case_path}'") 286 | generate_test_case_data(settings, case_path, args.type) 287 | 288 | 289 | if __name__ == "__main__": 290 | run() 291 | -------------------------------------------------------------------------------- /src/sd_jwt/common.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import secrets 5 | 6 | from base64 import urlsafe_b64decode, urlsafe_b64encode 7 | from dataclasses import dataclass 8 | from hashlib import sha256 9 | from json import loads 10 | from typing import List 11 | 12 | DEFAULT_SIGNING_ALG = "ES256" 13 | SD_DIGESTS_KEY = "_sd" 14 | DIGEST_ALG_KEY = "_sd_alg" 15 | KB_DIGEST_KEY = "sd_hash" 16 | SD_LIST_PREFIX = "..." 17 | JSON_SER_DISCLOSURE_KEY = "disclosures" 18 | JSON_SER_KB_JWT_KEY = "kb_jwt" 19 | 20 | logger = logging.getLogger("sd_jwt") 21 | 22 | 23 | @dataclass 24 | class SDObj: 25 | """This class can be used to make this part of the object selective disclosable.""" 26 | 27 | value: any 28 | 29 | # Make hashable 30 | def __hash__(self): 31 | return hash(self.value) 32 | 33 | 34 | class SDJWTHasSDClaimException(Exception): 35 | """Exception raised when input data contains the special _sd claim reserved for SD-JWT internal data.""" 36 | 37 | def __init__(self, error_location: any): 38 | super().__init__( 39 | f"Input data contains the special claim '{SD_DIGESTS_KEY}' reserved for SD-JWT internal data. Location: {error_location!r}" 40 | ) 41 | 42 | 43 | class SDJWTCommon: 44 | SD_JWT_HEADER = os.getenv( 45 | "SD_JWT_HEADER", "example+sd-jwt" 46 | ) # overwriteable with extra_header_parameters = {"typ": "other-example+sd-jwt"} 47 | KB_JWT_TYP_HEADER = "kb+jwt" 48 | HASH_ALG = {"name": "sha-256", "fn": sha256} 49 | 50 | COMBINED_SERIALIZATION_FORMAT_SEPARATOR = "~" 51 | 52 | unsafe_randomness = False 53 | 54 | def __init__(self, serialization_format): 55 | if serialization_format not in ("compact", "json"): 56 | raise ValueError(f"Unknown serialization format: {serialization_format}") 57 | self._serialization_format = serialization_format 58 | 59 | def _b64hash(self, raw): 60 | # Calculate the SHA 256 hash and output it base64 encoded 61 | return self._base64url_encode(self.HASH_ALG["fn"](raw).digest()) 62 | 63 | def _combine(self, *parts): 64 | return self.COMBINED_SERIALIZATION_FORMAT_SEPARATOR.join(parts) 65 | 66 | def _split(self, combined): 67 | return combined.split(self.COMBINED_SERIALIZATION_FORMAT_SEPARATOR) 68 | 69 | @staticmethod 70 | def _base64url_encode(data: bytes) -> str: 71 | return urlsafe_b64encode(data).decode("ascii").strip("=") 72 | 73 | @staticmethod 74 | def _base64url_decode(b64data: str) -> bytes: 75 | padded = f"{b64data}{'=' * divmod(len(b64data),4)[1]}" 76 | return urlsafe_b64decode(padded) 77 | 78 | def _generate_salt(self): 79 | if self.unsafe_randomness: 80 | # This is not cryptographically secure, but it is deterministic 81 | # and allows for repeatable output for the generation of the examples. 82 | logger.warning( 83 | "Using unsafe randomness is not suitable for production use." 84 | ) 85 | return self._base64url_encode( 86 | bytes(random.getrandbits(8) for _ in range(16)) 87 | ) 88 | else: 89 | return self._base64url_encode(secrets.token_bytes(16)) 90 | 91 | def _create_hash_mappings(self, disclosurses_list: List): 92 | # Mapping from hash of disclosure to the decoded disclosure 93 | self._hash_to_decoded_disclosure = {} 94 | 95 | # Mapping from hash of disclosure to the raw disclosure 96 | self._hash_to_disclosure = {} 97 | 98 | for disclosure in disclosurses_list: 99 | decoded_disclosure = loads( 100 | self._base64url_decode(disclosure).decode("utf-8") 101 | ) 102 | _hash = self._b64hash(disclosure.encode("ascii")) 103 | if _hash in self._hash_to_decoded_disclosure: 104 | raise ValueError( 105 | f"Duplicate disclosure hash {_hash} for disclosure {decoded_disclosure}" 106 | ) 107 | 108 | self._hash_to_decoded_disclosure[_hash] = decoded_disclosure 109 | self._hash_to_disclosure[_hash] = disclosure 110 | 111 | def _check_for_sd_claim(self, the_object): 112 | # Recursively check for the presence of the _sd claim, also 113 | # works for arrays and nested objects. 114 | if isinstance(the_object, dict): 115 | for key, value in the_object.items(): 116 | if key == SD_DIGESTS_KEY: 117 | raise SDJWTHasSDClaimException(the_object) 118 | else: 119 | self._check_for_sd_claim(value) 120 | elif isinstance(the_object, list): 121 | for item in the_object: 122 | self._check_for_sd_claim(item) 123 | else: 124 | return 125 | 126 | def _parse_sd_jwt(self, sd_jwt): 127 | if self._serialization_format == "compact": 128 | ( 129 | self._unverified_input_sd_jwt, 130 | *self._input_disclosures, 131 | self._unverified_input_key_binding_jwt 132 | ) = self._split(sd_jwt) 133 | 134 | # Extract only the body from SD-JWT without verifying the signature 135 | _, jwt_body, _ = self._unverified_input_sd_jwt.split(".") 136 | self._unverified_input_sd_jwt_payload = loads( 137 | self._base64url_decode(jwt_body) 138 | ) 139 | self._unverified_compact_serialized_input_sd_jwt = ( 140 | self._unverified_input_sd_jwt 141 | ) 142 | 143 | else: 144 | # if the SD-JWT is in JSON format, parse the json and extract the disclosures. 145 | self._unverified_input_sd_jwt = sd_jwt 146 | self._unverified_input_sd_jwt_parsed = loads(sd_jwt) 147 | 148 | self._unverified_input_sd_jwt_payload = loads( 149 | self._base64url_decode(self._unverified_input_sd_jwt_parsed["payload"]) 150 | ) 151 | 152 | # distinguish between flattened and general JSON serialization (RFC7515) 153 | if "signature" in self._unverified_input_sd_jwt_parsed: 154 | # flattened 155 | self._input_disclosures = self._unverified_input_sd_jwt_parsed[ 156 | "header" 157 | ][JSON_SER_DISCLOSURE_KEY] 158 | self._unverified_input_key_binding_jwt = ( 159 | self._unverified_input_sd_jwt_parsed["header"].get( 160 | JSON_SER_KB_JWT_KEY, "" 161 | ) 162 | ) 163 | self._unverified_compact_serialized_input_sd_jwt = ".".join( 164 | [ 165 | self._unverified_input_sd_jwt_parsed["protected"], 166 | self._unverified_input_sd_jwt_parsed["payload"], 167 | self._unverified_input_sd_jwt_parsed["signature"] 168 | ] 169 | ) 170 | 171 | elif "signatures" in self._unverified_input_sd_jwt_parsed: 172 | # general, look at the header in the first signature 173 | self._input_disclosures = self._unverified_input_sd_jwt_parsed[ 174 | "signatures" 175 | ][0]["header"][JSON_SER_DISCLOSURE_KEY] 176 | self._unverified_input_key_binding_jwt = ( 177 | self._unverified_input_sd_jwt_parsed["signatures"][0]["header"].get( 178 | JSON_SER_KB_JWT_KEY, "" 179 | ) 180 | ) 181 | self._unverified_compact_serialized_input_sd_jwt = ".".join( 182 | [ 183 | self._unverified_input_sd_jwt_parsed["signatures"][0][ 184 | "protected" 185 | ], 186 | self._unverified_input_sd_jwt_parsed["payload"], 187 | self._unverified_input_sd_jwt_parsed["signatures"][0][ 188 | "signature" 189 | ], 190 | ] 191 | ) 192 | 193 | else: 194 | raise ValueError("Invalid JSON serialization of SD-JWT") 195 | 196 | def _calculate_kb_hash(self, disclosures): 197 | # Temporarily create the combined presentation in order to create the hash over it 198 | # Note: For JSON Serialization, the compact representation of the SD-JWT is restored from the parsed JSON (see common.py) 199 | string_to_hash = self._combine( 200 | self._unverified_compact_serialized_input_sd_jwt, 201 | *disclosures, 202 | "" 203 | ) 204 | return self._b64hash(string_to_hash.encode("ascii")) 205 | -------------------------------------------------------------------------------- /src/sd_jwt/disclosure.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from json import dumps 3 | from typing import Optional 4 | 5 | 6 | @dataclass 7 | class SDJWTDisclosure: 8 | """This class represents a disclosure of a claim.""" 9 | 10 | issuer: any 11 | key: Optional[str] # only for object keys 12 | value: any 13 | 14 | def __post_init__(self): 15 | self._hash() 16 | 17 | def _hash(self): 18 | salt = self.issuer._generate_salt() 19 | if self.key is None: 20 | data = [salt, self.value] 21 | else: 22 | data = [salt, self.key, self.value] 23 | 24 | self._json = dumps(data).encode("utf-8") 25 | 26 | self._raw_b64 = self.issuer._base64url_encode(self._json) 27 | self._hash = self.issuer._b64hash(self._raw_b64.encode("ascii")) 28 | 29 | @property 30 | def hash(self): 31 | return self._hash 32 | 33 | @property 34 | def b64(self): 35 | return self._raw_b64 36 | 37 | @property 38 | def json(self): 39 | return self._json.decode("utf-8") 40 | -------------------------------------------------------------------------------- /src/sd_jwt/holder.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .common import ( 4 | SDJWTCommon, 5 | DEFAULT_SIGNING_ALG, 6 | SD_DIGESTS_KEY, 7 | SD_LIST_PREFIX, 8 | KB_DIGEST_KEY, 9 | JSON_SER_DISCLOSURE_KEY, 10 | JSON_SER_KB_JWT_KEY, 11 | ) 12 | from json import dumps 13 | from time import time 14 | from typing import Dict, List, Optional 15 | from itertools import zip_longest 16 | 17 | from jwcrypto.jws import JWS 18 | 19 | logger = logging.getLogger("sd_jwt") 20 | 21 | 22 | class SDJWTHolder(SDJWTCommon): 23 | hs_disclosures: List 24 | key_binding_jwt_header: Dict 25 | key_binding_jwt_payload: Dict 26 | key_binding_jwt: JWS 27 | serialized_key_binding_jwt: str = "" 28 | sd_jwt_presentation: str 29 | 30 | _input_disclosures: List 31 | _hash_to_decoded_disclosure: Dict 32 | _hash_to_disclosure: Dict 33 | 34 | def __init__(self, sd_jwt_issuance: str, serialization_format: str = "compact"): 35 | super().__init__(serialization_format=serialization_format) 36 | 37 | self._parse_sd_jwt(sd_jwt_issuance) 38 | 39 | # TODO: This holder does not verify the SD-JWT yet - this 40 | # is not strictly needed, but it would be nice to have. 41 | self.serialized_sd_jwt = self._unverified_input_sd_jwt 42 | self.sd_jwt_payload = self._unverified_input_sd_jwt_payload 43 | if self._serialization_format == "json": 44 | self.sd_jwt_parsed = self._unverified_input_sd_jwt_parsed 45 | 46 | self._create_hash_mappings(self._input_disclosures) 47 | 48 | def create_presentation( 49 | self, claims_to_disclose, nonce=None, aud=None, holder_key=None, sign_alg=None 50 | ): 51 | # Select the disclosures 52 | self.hs_disclosures = [] 53 | self._select_disclosures(self.sd_jwt_payload, claims_to_disclose) 54 | 55 | # Optional: Create a key binding JWT 56 | if nonce and aud and holder_key: 57 | sd_jwt_presentation_hash = self._calculate_kb_hash(self.hs_disclosures) 58 | self._create_key_binding_jwt( 59 | nonce, aud, sd_jwt_presentation_hash, holder_key, sign_alg 60 | ) 61 | 62 | # Create the combined presentation 63 | if self._serialization_format == "compact": 64 | # Note: If the key binding JWT is not created, then the 65 | # last element is empty, matching the spec. 66 | self.sd_jwt_presentation = self._combine( 67 | self.serialized_sd_jwt, 68 | *self.hs_disclosures, 69 | self.serialized_key_binding_jwt, 70 | ) 71 | else: 72 | # In this case, take the parsed JSON serialized SD-JWT and 73 | # only filter the disclosures in the header. Add the key 74 | # binding JWT to the header if it was created. 75 | presentation = self._unverified_input_sd_jwt_parsed 76 | if "signature" in presentation: 77 | # flattened JSON serialization 78 | presentation["header"][JSON_SER_DISCLOSURE_KEY] = self.hs_disclosures 79 | 80 | if self.serialized_key_binding_jwt: 81 | presentation["header"][ 82 | JSON_SER_KB_JWT_KEY 83 | ] = self.serialized_key_binding_jwt 84 | else: 85 | # general, add everything to first signature's header 86 | presentation["signatures"][0]["header"][ 87 | JSON_SER_DISCLOSURE_KEY 88 | ] = self.hs_disclosures 89 | 90 | if self.serialized_key_binding_jwt: 91 | presentation["signatures"][0]["header"][ 92 | JSON_SER_KB_JWT_KEY 93 | ] = self.serialized_key_binding_jwt 94 | 95 | self.sd_jwt_presentation = dumps(presentation) 96 | 97 | def _select_disclosures(self, sd_jwt_claims, claims_to_disclose): 98 | # Recursively process the claims in sd_jwt_claims. In each 99 | # object found therein, look at the SD_DIGESTS_KEY. If it 100 | # contains hash digests for claims that should be disclosed, 101 | # then add the corresponding disclosures to the claims_to_disclose. 102 | 103 | if type(sd_jwt_claims) is list: 104 | return self._select_disclosures_list(sd_jwt_claims, claims_to_disclose) 105 | elif type(sd_jwt_claims) is dict: 106 | return self._select_disclosures_dict(sd_jwt_claims, claims_to_disclose) 107 | else: 108 | pass 109 | 110 | def _select_disclosures_list(self, sd_jwt_claims, claims_to_disclose): 111 | if claims_to_disclose is None: 112 | return [] 113 | if claims_to_disclose is True: 114 | claims_to_disclose = [] 115 | if not type(claims_to_disclose) is list: 116 | raise ValueError( 117 | f"To disclose array elements, an array must be provided as disclosure information.\n" 118 | f"Found {claims_to_disclose} instead.\n" 119 | f"Check disclosure information for array: {sd_jwt_claims}" 120 | ) 121 | 122 | for pos, (claims_to_disclose_element, element) in enumerate( 123 | zip_longest(claims_to_disclose, sd_jwt_claims, fillvalue=None) 124 | ): 125 | if ( 126 | isinstance(element, dict) 127 | and len(element) == 1 128 | and SD_LIST_PREFIX in element 129 | and type(element[SD_LIST_PREFIX]) is str 130 | ): 131 | digest_to_check = element[SD_LIST_PREFIX] 132 | if digest_to_check not in self._hash_to_decoded_disclosure: 133 | # fake digest 134 | continue 135 | 136 | # Determine type of disclosure 137 | _, disclosure_value = self._hash_to_decoded_disclosure[digest_to_check] 138 | 139 | # Disclose the claim only if in claims_to_disclose (assumed to be an array) 140 | # there is an element with the current index and it is not None or False 141 | if claims_to_disclose_element in ( 142 | False, 143 | None, 144 | ): 145 | continue 146 | 147 | self.hs_disclosures.append(self._hash_to_disclosure[digest_to_check]) 148 | if isinstance(disclosure_value, dict): 149 | if claims_to_disclose_element is True: 150 | # Tolerate a "True" for a disclosure of an object 151 | claims_to_disclose_element = {} 152 | if not isinstance(claims_to_disclose_element, dict): 153 | raise ValueError( 154 | f"To disclose object elements in arrays, provide an object (can be empty).\n" 155 | f"Found {claims_to_disclose_element} instead.\n" 156 | f"Problem at position {pos} of {claims_to_disclose}.\n" 157 | f"Check disclosure information for object: {sd_jwt_claims}" 158 | ) 159 | self._select_disclosures( 160 | disclosure_value, claims_to_disclose_element 161 | ) 162 | elif isinstance(disclosure_value, list): 163 | if claims_to_disclose_element is True: 164 | # Tolerate a "True" for a disclosure of an array 165 | claims_to_disclose_element = [] 166 | if not isinstance(claims_to_disclose_element, list): 167 | raise ValueError( 168 | f"To disclose array elements nested in arrays, provide an array (can be empty).\n" 169 | f"Found {claims_to_disclose_element} instead.\n" 170 | f"Problem at position {pos} of {claims_to_disclose}.\n" 171 | f"Check disclosure information for array: {sd_jwt_claims}" 172 | ) 173 | 174 | self._select_disclosures( 175 | disclosure_value, claims_to_disclose_element 176 | ) 177 | 178 | else: 179 | self._select_disclosures(element, claims_to_disclose_element) 180 | 181 | def _select_disclosures_dict(self, sd_jwt_claims, claims_to_disclose): 182 | if claims_to_disclose is None: 183 | return {} 184 | if claims_to_disclose is True: 185 | # Tolerate a "True" for a disclosure of an object 186 | claims_to_disclose = {} 187 | if not isinstance(claims_to_disclose, dict): 188 | raise ValueError( 189 | f"To disclose object elements, an object must be provided as disclosure information.\n" 190 | f"Found {claims_to_disclose} (type {type(claims_to_disclose)}) instead.\n" 191 | f"Check disclosure information for object: {sd_jwt_claims}" 192 | ) 193 | for key, value in sd_jwt_claims.items(): 194 | if key == SD_DIGESTS_KEY: 195 | for digest_to_check in value: 196 | if digest_to_check not in self._hash_to_decoded_disclosure: 197 | # fake digest 198 | continue 199 | _, key, value = self._hash_to_decoded_disclosure[digest_to_check] 200 | 201 | try: 202 | logger.debug( 203 | f"In _select_disclosures_dict: {key}, {value}, {claims_to_disclose}" 204 | ) 205 | if key in claims_to_disclose and claims_to_disclose[key]: 206 | logger.debug(f"Adding disclosure for {digest_to_check}") 207 | self.hs_disclosures.append( 208 | self._hash_to_disclosure[digest_to_check] 209 | ) 210 | else: 211 | logger.debug( 212 | f"Not adding disclosure for {digest_to_check}, {key} (type {type(key)}) not in {claims_to_disclose}" 213 | ) 214 | except TypeError: 215 | # claims_to_disclose is not a dict 216 | raise TypeError( 217 | f"claims_to_disclose does not contain a dict where a dict was expected (found {claims_to_disclose} instead)\n" 218 | f"Check claims_to_disclose for key: {key}, value: {value}" 219 | ) from None 220 | 221 | self._select_disclosures(value, claims_to_disclose.get(key, None)) 222 | else: 223 | self._select_disclosures(value, claims_to_disclose.get(key, None)) 224 | 225 | def _create_key_binding_jwt( 226 | self, nonce, aud, presentation_hash, holder_key, sign_alg: Optional[str] = None 227 | ): 228 | _alg = sign_alg or DEFAULT_SIGNING_ALG 229 | 230 | self.key_binding_jwt_header = { 231 | "alg": _alg, 232 | "typ": self.KB_JWT_TYP_HEADER, 233 | } 234 | 235 | self.key_binding_jwt_payload = { 236 | "nonce": nonce, 237 | "aud": aud, 238 | "iat": int(time()), 239 | KB_DIGEST_KEY: presentation_hash, 240 | } 241 | 242 | # Sign the SD-JWT-Release using the holder's key 243 | self.key_binding_jwt = JWS( 244 | payload=dumps(self.key_binding_jwt_payload), 245 | ) 246 | 247 | self.key_binding_jwt.add_signature( 248 | holder_key, 249 | alg=_alg, 250 | protected=dumps(self.key_binding_jwt_header), 251 | ) 252 | self.serialized_key_binding_jwt = self.key_binding_jwt.serialize(compact=True) 253 | -------------------------------------------------------------------------------- /src/sd_jwt/issuer.py: -------------------------------------------------------------------------------- 1 | import random 2 | from json import dumps 3 | from typing import Dict, List, Union 4 | 5 | from jwcrypto.jws import JWS 6 | 7 | from .common import ( 8 | DEFAULT_SIGNING_ALG, 9 | DIGEST_ALG_KEY, 10 | SD_DIGESTS_KEY, 11 | SD_LIST_PREFIX, 12 | JSON_SER_DISCLOSURE_KEY, 13 | SDJWTCommon, 14 | SDObj, 15 | ) 16 | from .disclosure import SDJWTDisclosure 17 | 18 | 19 | class SDJWTIssuer(SDJWTCommon): 20 | DECOY_MIN_ELEMENTS = 2 21 | DECOY_MAX_ELEMENTS = 5 22 | 23 | sd_jwt_payload: Dict 24 | sd_jwt: JWS 25 | serialized_sd_jwt: str 26 | 27 | ii_disclosures: List 28 | sd_jwt_issuance: str 29 | 30 | decoy_digests: List 31 | 32 | def __init__( 33 | self, 34 | user_claims: Dict, 35 | issuer_keys: Union[Dict, List[Dict]], 36 | holder_key=None, 37 | sign_alg=None, 38 | add_decoy_claims: bool = False, 39 | serialization_format: str = "compact", 40 | extra_header_parameters: dict = {}, 41 | ): 42 | super().__init__(serialization_format=serialization_format) 43 | 44 | self._user_claims = user_claims 45 | if not isinstance(issuer_keys, list): 46 | issuer_keys = [issuer_keys] 47 | self._issuer_keys = issuer_keys 48 | self._holder_key = holder_key 49 | self._sign_alg = sign_alg or DEFAULT_SIGNING_ALG 50 | self._add_decoy_claims = add_decoy_claims 51 | self._extra_header_parameters = extra_header_parameters 52 | 53 | self.ii_disclosures = [] 54 | self.decoy_digests = [] 55 | 56 | if len(self._issuer_keys) > 1 and self._serialization_format != "json": 57 | raise ValueError( 58 | f"Multiple issuer keys (here {len(self._issuer_keys)}) are only supported with JSON serialization." 59 | f"\nKeys found: {self._issuer_keys}" 60 | ) 61 | 62 | self._check_for_sd_claim(self._user_claims) 63 | self._assemble_sd_jwt_payload() 64 | self._create_signed_jws() 65 | self._create_combined() 66 | 67 | def _assemble_sd_jwt_payload(self): 68 | # Create the JWS payload 69 | self.sd_jwt_payload = self._create_sd_claims(self._user_claims) 70 | self.sd_jwt_payload.update( 71 | { 72 | DIGEST_ALG_KEY: self.HASH_ALG["name"], 73 | } 74 | ) 75 | if self._holder_key: 76 | self.sd_jwt_payload["cnf"] = { 77 | "jwk": self._holder_key.export_public(as_dict=True) 78 | } 79 | 80 | def _create_decoy_claim_entry(self) -> str: 81 | digest = self._b64hash(self._generate_salt().encode("ascii")) 82 | self.decoy_digests.append(digest) 83 | return digest 84 | 85 | def _create_sd_claims(self, user_claims): 86 | # This function can be called recursively. 87 | # 88 | # If the user claims are a list, apply this function 89 | # to each item in the list. 90 | if isinstance(user_claims, list): 91 | return self._create_sd_claims_list(user_claims) 92 | 93 | # If the user claims are a dictionary, apply this function 94 | # to each key/value pair in the dictionary. 95 | elif isinstance(user_claims, dict): 96 | return self._create_sd_claims_object(user_claims) 97 | 98 | # For other types, assume that the value can be disclosed. 99 | elif isinstance(user_claims, SDObj): 100 | raise ValueError( 101 | f"SDObj found in illegal place.\nThe claim value '{user_claims}' should not be wrapped by SDObj." 102 | ) 103 | return user_claims 104 | 105 | def _create_sd_claims_list(self, user_claims: List): 106 | # Walk through all elements in the list. 107 | # If an element is marked as SD, then create a proper disclosure for it. 108 | # Otherwise, just return the element. 109 | 110 | output_user_claims = [] 111 | for claim in user_claims: 112 | if isinstance(claim, SDObj): 113 | subtree_from_here = self._create_sd_claims(claim.value) 114 | # Create a new disclosure 115 | disclosure = SDJWTDisclosure( 116 | self, 117 | key=None, 118 | value=subtree_from_here, 119 | ) 120 | 121 | # Add to ii_disclosures 122 | self.ii_disclosures.append(disclosure) 123 | 124 | # Assemble all hash digests in the disclosures list. 125 | output_user_claims.append({SD_LIST_PREFIX: disclosure.hash}) 126 | else: 127 | subtree_from_here = self._create_sd_claims(claim) 128 | output_user_claims.append(subtree_from_here) 129 | 130 | return output_user_claims 131 | 132 | def _create_sd_claims_object(self, user_claims: Dict): 133 | sd_claims = {SD_DIGESTS_KEY: []} 134 | for key, value in user_claims.items(): 135 | subtree_from_here = self._create_sd_claims(value) 136 | if isinstance(key, SDObj): 137 | # Create a new disclosure 138 | disclosure = SDJWTDisclosure( 139 | self, 140 | key=key.value, 141 | value=subtree_from_here, 142 | ) 143 | 144 | # Add to ii_disclosures 145 | self.ii_disclosures.append(disclosure) 146 | 147 | # Assemble all hash digests in the disclosures list. 148 | sd_claims[SD_DIGESTS_KEY].append(disclosure.hash) 149 | else: 150 | sd_claims[key] = subtree_from_here 151 | 152 | # Add decoy claims if requested 153 | if self._add_decoy_claims: 154 | for _ in range( 155 | random.randint(self.DECOY_MIN_ELEMENTS, self.DECOY_MAX_ELEMENTS) 156 | ): 157 | sd_claims[SD_DIGESTS_KEY].append(self._create_decoy_claim_entry()) 158 | 159 | # Delete the SD_DIGESTS_KEY if it is empty 160 | if len(sd_claims[SD_DIGESTS_KEY]) == 0: 161 | del sd_claims[SD_DIGESTS_KEY] 162 | else: 163 | # Sort the hash digests otherwise 164 | sd_claims[SD_DIGESTS_KEY].sort() 165 | 166 | return sd_claims 167 | 168 | def _create_signed_jws(self): 169 | """ 170 | Create the SD-JWT. 171 | 172 | If serialization_format is "compact", then the SD-JWT is a JWT (JWS in compact serialization). 173 | If serialization_format is "json", then the SD-JWT is a JWS in JSON serialization. The disclosures in this case 174 | will be added in a separate "disclosures" property of the JSON. 175 | """ 176 | 177 | self.sd_jwt = JWS(payload=dumps(self.sd_jwt_payload)) 178 | # Assemble protected headers starting with default 179 | _protected_headers = {"alg": self._sign_alg, "typ": self.SD_JWT_HEADER} 180 | if len(self._issuer_keys) == 1 and "kid" in self._issuer_keys[0]: 181 | _protected_headers["kid"] = self._issuer_keys[0]["kid"] 182 | 183 | # override if any 184 | _protected_headers.update(self._extra_header_parameters) 185 | 186 | for i, key in enumerate(self._issuer_keys): 187 | header = {"kid": key["kid"]} if "kid" in key else None 188 | 189 | # for json-serialization, add the disclosures to the first header 190 | if self._serialization_format == "json" and i == 0: 191 | header = header or {} 192 | header[JSON_SER_DISCLOSURE_KEY] = [d.b64 for d in self.ii_disclosures] 193 | 194 | self.sd_jwt.add_signature( 195 | key, 196 | alg=self._sign_alg, 197 | protected=dumps(_protected_headers), 198 | header=header, 199 | ) 200 | 201 | self.serialized_sd_jwt = self.sd_jwt.serialize( 202 | compact=(self._serialization_format == "compact") 203 | ) 204 | 205 | def _create_combined(self): 206 | if self._serialization_format == "compact": 207 | self.sd_jwt_issuance = self._combine( 208 | self.serialized_sd_jwt, *(d.b64 for d in self.ii_disclosures) 209 | ) 210 | self.sd_jwt_issuance += self.COMBINED_SERIALIZATION_FORMAT_SEPARATOR 211 | else: 212 | self.sd_jwt_issuance = self.serialized_sd_jwt 213 | -------------------------------------------------------------------------------- /src/sd_jwt/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation-labs/sd-jwt-python/cde613902c4e23ae7160aa30966a6a5e7c658535/src/sd_jwt/utils/__init__.py -------------------------------------------------------------------------------- /src/sd_jwt/utils/demo_settings.yml: -------------------------------------------------------------------------------- 1 | identifiers: 2 | issuer: "https://example.com/issuer" 3 | verifier: "https://example.com/verifier" 4 | 5 | key_settings: 6 | key_size: 256 7 | 8 | kty: EC 9 | 10 | issuer_key: 11 | kty: EC 12 | d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g 13 | crv: P-256 14 | x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ 15 | y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 16 | 17 | holder_key: 18 | kty: EC 19 | d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I 20 | crv: P-256 21 | x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc 22 | y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ 23 | 24 | key_binding_nonce: "1234567890" 25 | -------------------------------------------------------------------------------- /src/sd_jwt/utils/demo_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import random 5 | import yaml 6 | import sys 7 | 8 | from jwcrypto.jwk import JWK, JWKSet 9 | from typing import Union 10 | 11 | logger = logging.getLogger("sd_jwt") 12 | 13 | 14 | def load_yaml_settings(file): 15 | with open(file, "r") as f: 16 | settings = yaml.safe_load(f) 17 | 18 | for property in ("identifiers", "key_settings"): 19 | if property not in settings: 20 | sys.exit(f"Settings file must define '{property}'.") 21 | 22 | # 'issuer_key' can be used instead of 'issuer_keys' in the key settings; will be converted to an array anyway 23 | if "issuer_key" in settings["key_settings"]: 24 | if "issuer_keys" in settings["key_settings"]: 25 | sys.exit("Settings file cannot define both 'issuer_key' and 'issuer_keys'.") 26 | 27 | settings["key_settings"]["issuer_keys"] = [settings["key_settings"]["issuer_key"]] 28 | 29 | return settings 30 | 31 | 32 | def print_repr(values: Union[str, list], nlines=2): 33 | value = "\n".join(values) if isinstance(values, (list, tuple)) else values 34 | _nlines = "\n" * nlines if nlines else "" 35 | print(value, end=_nlines) 36 | 37 | 38 | def print_decoded_repr(value: str, nlines=2): 39 | seq = [] 40 | for i in value.split("."): 41 | try: 42 | padded = f"{i}{'=' * divmod(len(i),4)[1]}" 43 | seq.append(f"{base64.urlsafe_b64decode(padded).decode()}") 44 | except Exception as e: 45 | logging.debug(f"{e} - for value: {i}") 46 | seq.append(i) 47 | _nlines = "\n" * nlines if nlines else "" 48 | print("\n.\n".join(seq), end=_nlines) 49 | 50 | 51 | def get_jwk(jwk_kwargs: dict = {}, no_randomness: bool = False, random_seed: int = 0): 52 | """ 53 | jwk_kwargs = { 54 | issuer_keys:list : [{}], 55 | holder_key:dict : {}, 56 | key_size: int : 0, 57 | kty: str : "RSA" 58 | } 59 | 60 | returns static or random JWK 61 | """ 62 | if no_randomness: 63 | random.seed(random_seed) 64 | issuer_keys = [JWK.from_json(json.dumps(k)) for k in jwk_kwargs["issuer_keys"]] 65 | holder_key = JWK.from_json(json.dumps(jwk_kwargs["holder_key"])) 66 | logger.warning("Using fixed randomness for demo purposes") 67 | else: 68 | _kwargs = {"key_size": jwk_kwargs["key_size"], "kty": jwk_kwargs["kty"]} 69 | issuer_keys = [JWK.generate(**_kwargs)] 70 | holder_key = JWK.generate(**_kwargs) 71 | 72 | if len(issuer_keys) > 1: 73 | issuer_public_keys = JWKSet() 74 | for k in issuer_keys: 75 | issuer_public_keys.add(JWK.from_json(k.export_public())) 76 | else: 77 | issuer_public_keys = JWK.from_json(issuer_keys[0].export_public()) 78 | 79 | return dict( 80 | issuer_keys=issuer_keys, 81 | holder_key=holder_key, 82 | issuer_public_keys=issuer_public_keys, 83 | ) 84 | -------------------------------------------------------------------------------- /src/sd_jwt/utils/formatting.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from textwrap import fill, wrap 4 | from pathlib import Path 5 | 6 | from sd_jwt.common import SDObj 7 | 8 | logger = logging.getLogger("sd_jwt") 9 | 10 | OUTPUT_INDENT = 2 11 | EXAMPLE_MAX_WIDTH = 68 12 | EXAMPLE_SHORT_WIDTH = 60 13 | OUTPUT_ENSURE_ASCII = False 14 | 15 | ####################################################################### 16 | # Helper functions to format examples 17 | ####################################################################### 18 | 19 | 20 | def textwrap_json(data, width=EXAMPLE_MAX_WIDTH): 21 | text = json.dumps(data, indent=OUTPUT_INDENT, ensure_ascii=OUTPUT_ENSURE_ASCII) 22 | output = [] 23 | for line in text.splitlines(): 24 | if len(line) <= width: 25 | output.append(line) 26 | else: 27 | # Check if line is of the form "key": "value" 28 | if not line.strip().startswith('"'): 29 | logger.warning("unexpected line " + line) 30 | output.append(line) 31 | continue 32 | # Determine number of spaces before the value 33 | ##spaces = line.index(":") + 2 34 | spaces = line.index('"') + OUTPUT_INDENT 35 | # Wrap the value 36 | wrapped = wrap( 37 | line[spaces:], 38 | width=width - spaces, 39 | break_on_hyphens=False, 40 | replace_whitespace=False, 41 | ) 42 | # Add the wrapped value to the output 43 | output.append(line[:spaces] + wrapped[0]) 44 | for line in wrapped[1:]: 45 | output.append(" " * spaces + line) 46 | output = "\n".join(text for text in output) 47 | 48 | return output 49 | 50 | 51 | def textwrap_text(text, width=EXAMPLE_MAX_WIDTH): 52 | return fill( 53 | text, 54 | width=width, 55 | break_on_hyphens=False, 56 | ) 57 | 58 | 59 | def multiline_code(text): 60 | # Add a ` character to each start and end of a line and a backslash after each line 61 | return "\\\n".join(f"`{line}`" for line in text.splitlines()) 62 | 63 | 64 | def markdown_disclosures(disclosures): 65 | markdown = "" 66 | for d in disclosures: 67 | if d.key is None: 68 | markdown += f"__Array Entry__:\n\n" 69 | else: 70 | markdown += f"__Claim `{d.key}`__:\n\n" 71 | 72 | markdown += ( 73 | f" * SHA-256 Hash: `{d.hash}`\n" 74 | f" * Disclosure:\\\n" 75 | f"{multiline_code(textwrap_text(d.b64, EXAMPLE_SHORT_WIDTH))}\n" 76 | f" * Contents:\n" 77 | f"{multiline_code(textwrap_text(d.json, EXAMPLE_SHORT_WIDTH))}\n\n\n" 78 | ) 79 | 80 | return markdown.strip() 81 | 82 | 83 | def markdown_decoy_digests(decoy_digests): 84 | # create a list of decoy digests in markdown format 85 | return "\n".join(f" * `{digest}`" for digest in decoy_digests) 86 | 87 | 88 | def format_for_testcase(data, ftype): 89 | if ftype == "json": 90 | return json.dumps(data, indent=OUTPUT_INDENT, ensure_ascii=OUTPUT_ENSURE_ASCII) 91 | else: 92 | return data 93 | 94 | 95 | def format_for_example(data, ftype): 96 | if ftype == "json": 97 | if isinstance(data, str): 98 | data = json.loads(data) 99 | return textwrap_json(data) 100 | elif ftype == "txt": 101 | return textwrap_text(data) 102 | else: 103 | return data 104 | -------------------------------------------------------------------------------- /src/sd_jwt/utils/yaml_specification.py: -------------------------------------------------------------------------------- 1 | from sd_jwt.common import SDObj 2 | import yaml 3 | import sys 4 | 5 | 6 | def load_yaml_specification(file): 7 | # create new resolver for tags 8 | with open(file, "r") as f: 9 | example = _yaml_load_specification(f) 10 | 11 | for property in ("user_claims", "holder_disclosed_claims"): 12 | if property not in example: 13 | sys.exit(f"Specification file must define '{property}'.") 14 | 15 | return example 16 | 17 | def _yaml_load_specification(f): 18 | resolver = yaml.resolver.Resolver() 19 | 20 | # Define custom YAML tag to indicate selective disclosure 21 | class SDKeyTag(yaml.YAMLObject): 22 | yaml_tag = "!sd" 23 | 24 | @classmethod 25 | def from_yaml(cls, loader, node): 26 | # If this is a scalar node, it can be a string, int, float, etc.; unfortunately, since we tagged 27 | # it with !sd, we cannot rely on the default YAML loader to parse it into the correct data type. 28 | # Instead, we must manually resolve it. 29 | if isinstance(node, yaml.ScalarNode): 30 | # If the 'style' is '"', then the scalar is a string; otherwise, we must resolve it. 31 | if node.style == '"': 32 | mp = loader.construct_yaml_str(node) 33 | else: 34 | resolved_type = resolver.resolve(yaml.ScalarNode, node.value, (True, False)) 35 | if resolved_type == "tag:yaml.org,2002:str": 36 | mp = loader.construct_yaml_str(node) 37 | elif resolved_type == "tag:yaml.org,2002:int": 38 | mp = loader.construct_yaml_int(node) 39 | elif resolved_type == "tag:yaml.org,2002:float": 40 | mp = loader.construct_yaml_float(node) 41 | elif resolved_type == "tag:yaml.org,2002:bool": 42 | mp = loader.construct_yaml_bool(node) 43 | elif resolved_type == "tag:yaml.org,2002:null": 44 | mp = None 45 | else: 46 | raise Exception( 47 | f"Unsupported scalar type for selective disclosure (!sd): {resolved_type}; node is {node}, style is {node.style}" 48 | ) 49 | return SDObj(mp) 50 | elif isinstance(node, yaml.MappingNode): 51 | return SDObj(loader.construct_mapping(node)) 52 | elif isinstance(node, yaml.SequenceNode): 53 | return SDObj(loader.construct_sequence(node)) 54 | else: 55 | raise Exception( 56 | "Unsupported node type for selective disclosure (!sd): {}".format( 57 | node 58 | ) 59 | ) 60 | 61 | return yaml.load(f, Loader=yaml.FullLoader) 62 | 63 | """ 64 | Takes an object that has been parsed from a YAML file and removes the SDObj wrappers. 65 | """ 66 | def remove_sdobj_wrappers(data): 67 | if isinstance(data, SDObj): 68 | return remove_sdobj_wrappers(data.value) 69 | elif isinstance(data, dict): 70 | return {remove_sdobj_wrappers(key): remove_sdobj_wrappers(value) for key, value in data.items()} 71 | elif isinstance(data, list): 72 | return [remove_sdobj_wrappers(value) for value in data] 73 | else: 74 | return data 75 | -------------------------------------------------------------------------------- /src/sd_jwt/verifier.py: -------------------------------------------------------------------------------- 1 | from .common import ( 2 | SDJWTCommon, 3 | DEFAULT_SIGNING_ALG, 4 | DIGEST_ALG_KEY, 5 | SD_DIGESTS_KEY, 6 | SD_LIST_PREFIX, 7 | KB_DIGEST_KEY, 8 | ) 9 | 10 | from json import dumps, loads 11 | from typing import Dict, List, Union, Callable 12 | 13 | from jwcrypto.jwk import JWK 14 | from jwcrypto.jws import JWS 15 | 16 | 17 | class SDJWTVerifier(SDJWTCommon): 18 | _input_disclosures: List 19 | _hash_to_decoded_disclosure: Dict 20 | _hash_to_disclosure: Dict 21 | 22 | def __init__( 23 | self, 24 | sd_jwt_presentation: str, 25 | cb_get_issuer_key: Callable[[str, Dict], str], 26 | expected_aud: Union[str, None] = None, 27 | expected_nonce: Union[str, None] = None, 28 | serialization_format: str = "compact", 29 | ): 30 | super().__init__(serialization_format=serialization_format) 31 | 32 | self._parse_sd_jwt(sd_jwt_presentation) 33 | self._create_hash_mappings(self._input_disclosures) 34 | self._verify_sd_jwt(cb_get_issuer_key) 35 | 36 | # expected aud and nonce either need to be both set or both None 37 | if expected_aud or expected_nonce: 38 | if not (expected_aud and expected_nonce): 39 | raise ValueError( 40 | "Either both expected_aud and expected_nonce must be provided or both must be None" 41 | ) 42 | 43 | # Verify the SD-JWT-Release 44 | self._verify_key_binding_jwt( 45 | expected_aud, 46 | expected_nonce, 47 | ) 48 | 49 | def get_verified_payload(self): 50 | return self._extract_sd_claims() 51 | 52 | def _verify_sd_jwt( 53 | self, 54 | cb_get_issuer_key, 55 | sign_alg: str = None, 56 | ): 57 | parsed_input_sd_jwt = JWS() 58 | parsed_input_sd_jwt.deserialize(self._unverified_input_sd_jwt) 59 | 60 | unverified_issuer = self._unverified_input_sd_jwt_payload.get("iss", None) 61 | unverified_header_parameters = parsed_input_sd_jwt.jose_header 62 | issuer_public_key = cb_get_issuer_key( 63 | unverified_issuer, unverified_header_parameters 64 | ) 65 | parsed_input_sd_jwt.verify(issuer_public_key, alg=sign_alg) 66 | 67 | self._sd_jwt_payload = loads(parsed_input_sd_jwt.payload.decode("utf-8")) 68 | # TODO: Check exp/nbf/iat 69 | 70 | self._holder_public_key_payload = self._sd_jwt_payload.get("cnf", None) 71 | 72 | def _verify_key_binding_jwt( 73 | self, 74 | expected_aud: Union[str, None] = None, 75 | expected_nonce: Union[str, None] = None, 76 | sign_alg: Union[str, None] = None, 77 | ): 78 | 79 | # Deserialized the key binding JWT 80 | _alg = sign_alg or DEFAULT_SIGNING_ALG 81 | parsed_input_key_binding_jwt = JWS() 82 | parsed_input_key_binding_jwt.deserialize(self._unverified_input_key_binding_jwt) 83 | 84 | # Verify the key binding JWT using the holder public key 85 | if not self._holder_public_key_payload: 86 | raise ValueError("No holder public key in SD-JWT") 87 | 88 | holder_public_key_payload_jwk = self._holder_public_key_payload.get("jwk", None) 89 | if not holder_public_key_payload_jwk: 90 | raise ValueError( 91 | "The holder_public_key_payload is malformed. " 92 | "It doesn't contain the claim jwk: " 93 | f"{self._holder_public_key_payload}" 94 | ) 95 | 96 | pubkey = JWK.from_json(dumps(holder_public_key_payload_jwk)) 97 | 98 | parsed_input_key_binding_jwt.verify(pubkey, alg=_alg) 99 | 100 | # Check header typ 101 | key_binding_jwt_header = parsed_input_key_binding_jwt.jose_header 102 | 103 | if key_binding_jwt_header["typ"] != self.KB_JWT_TYP_HEADER: 104 | raise ValueError("Invalid header typ") 105 | 106 | # Check payload 107 | key_binding_jwt_payload = loads(parsed_input_key_binding_jwt.payload) 108 | 109 | if key_binding_jwt_payload["aud"] != expected_aud: 110 | raise ValueError("Invalid audience in KB-JWT") 111 | if key_binding_jwt_payload["nonce"] != expected_nonce: 112 | raise ValueError("Invalid nonce in KB-JWT") 113 | 114 | # Reassemble the SD-JWT in compact format and check digest 115 | if self._serialization_format == "compact": 116 | expected_sd_jwt_presentation_hash = self._calculate_kb_hash( 117 | self._input_disclosures 118 | ) 119 | 120 | if ( 121 | key_binding_jwt_payload[KB_DIGEST_KEY] 122 | != expected_sd_jwt_presentation_hash 123 | ): 124 | raise ValueError("Invalid digest in KB-JWT") 125 | 126 | def _extract_sd_claims(self): 127 | if DIGEST_ALG_KEY in self._sd_jwt_payload: 128 | if self._sd_jwt_payload[DIGEST_ALG_KEY] != self.HASH_ALG["name"]: 129 | # TODO: Support other hash algorithms 130 | raise ValueError("Invalid hash algorithm") 131 | 132 | self._duplicate_hash_check = [] 133 | return self._unpack_disclosed_claims(self._sd_jwt_payload) 134 | 135 | def _unpack_disclosed_claims(self, sd_jwt_claims): 136 | # In a list, unpack each element individually 137 | if type(sd_jwt_claims) is list: 138 | output = [] 139 | for element in sd_jwt_claims: 140 | if ( 141 | type(element) is dict 142 | and len(element) == 1 143 | and SD_LIST_PREFIX in element 144 | and type(element[SD_LIST_PREFIX]) is str 145 | ): 146 | digest_to_check = element[SD_LIST_PREFIX] 147 | if digest_to_check in self._hash_to_decoded_disclosure: 148 | _, value = self._hash_to_decoded_disclosure[digest_to_check] 149 | output.append(self._unpack_disclosed_claims(value)) 150 | else: 151 | output.append(self._unpack_disclosed_claims(element)) 152 | return output 153 | 154 | elif type(sd_jwt_claims) is dict: 155 | # First, try to figure out if there are any claims to be 156 | # disclosed in this dict. If so, replace them by their 157 | # disclosed values. 158 | 159 | pre_output = { 160 | k: self._unpack_disclosed_claims(v) 161 | for k, v in sd_jwt_claims.items() 162 | if k != SD_DIGESTS_KEY and k != DIGEST_ALG_KEY 163 | } 164 | 165 | for digest in sd_jwt_claims.get(SD_DIGESTS_KEY, []): 166 | if digest in self._duplicate_hash_check: 167 | raise ValueError(f"Duplicate hash found in SD-JWT: {digest}") 168 | self._duplicate_hash_check.append(digest) 169 | 170 | if digest in self._hash_to_decoded_disclosure: 171 | _, key, value = self._hash_to_decoded_disclosure[digest] 172 | if key in pre_output: 173 | raise ValueError( 174 | f"Duplicate key found when unpacking disclosed claim: '{key}' in {pre_output}. This is not allowed." 175 | ) 176 | unpacked_value = self._unpack_disclosed_claims(value) 177 | pre_output[key] = unpacked_value 178 | 179 | # Now, go through the dict and unpack any nested dicts. 180 | 181 | return pre_output 182 | 183 | else: 184 | return sd_jwt_claims 185 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import pytest 3 | 4 | from sd_jwt.utils.yaml_specification import load_yaml_specification 5 | from sd_jwt.utils.demo_utils import load_yaml_settings 6 | 7 | tc_basedir = Path(__file__).parent / "testcases" 8 | 9 | def pytest_generate_tests(metafunc): 10 | # load all test cases from the subdirectory "testcases" below the current file's directory 11 | # and generate a test case for each one 12 | if "testcase" in metafunc.fixturenames: 13 | testcases = list(tc_basedir.glob("*/specification.yml")) 14 | metafunc.parametrize( 15 | "testcase", [load_yaml_specification(t) for t in testcases], ids=[t.parent.name for t in testcases] 16 | ) 17 | 18 | @pytest.fixture 19 | def settings(): 20 | settings_file = tc_basedir / "settings.yml" 21 | return load_yaml_settings(settings_file) 22 | -------------------------------------------------------------------------------- /tests/test_disclose_all_shortcut.py: -------------------------------------------------------------------------------- 1 | from sd_jwt import __version__ 2 | from sd_jwt.issuer import SDJWTIssuer 3 | from sd_jwt.utils.demo_utils import get_jwk 4 | from sd_jwt.verifier import SDJWTVerifier 5 | from sd_jwt.utils.yaml_specification import remove_sdobj_wrappers 6 | 7 | 8 | def test_e2e(testcase, settings): 9 | settings.update(testcase.get("settings_override", {})) 10 | seed = settings["random_seed"] 11 | demo_keys = get_jwk(settings["key_settings"], True, seed) 12 | use_decoys = testcase.get("add_decoy_claims", False) 13 | serialization_format = testcase.get("serialization_format", "compact") 14 | 15 | extra_header_parameters = {"typ": "testcase+sd-jwt"} 16 | extra_header_parameters.update(testcase.get("extra_header_parameters", {})) 17 | 18 | # Issuer: Produce SD-JWT and issuance format for selected example 19 | 20 | user_claims = {"iss": settings["identifiers"]["issuer"]} 21 | user_claims.update(testcase["user_claims"]) 22 | 23 | SDJWTIssuer.unsafe_randomness = True 24 | sdjwt_at_issuer = SDJWTIssuer( 25 | user_claims, 26 | demo_keys["issuer_keys"], 27 | demo_keys["holder_key"] if testcase.get("key_binding", False) else None, 28 | add_decoy_claims=use_decoys, 29 | serialization_format=serialization_format, 30 | extra_header_parameters=extra_header_parameters, 31 | ) 32 | 33 | output_issuance = sdjwt_at_issuer.sd_jwt_issuance 34 | 35 | # This test skips the holder's part and goes straight to the verifier. 36 | # We disable key binding checks. 37 | output_holder = output_issuance 38 | 39 | # Verifier 40 | sdjwt_header_parameters = {} 41 | 42 | def cb_get_issuer_key(issuer, header_parameters): 43 | if type(header_parameters) == dict: 44 | sdjwt_header_parameters.update(header_parameters) 45 | return demo_keys["issuer_public_keys"] 46 | 47 | sdjwt_at_verifier = SDJWTVerifier( 48 | output_holder, 49 | cb_get_issuer_key, 50 | None, 51 | None, 52 | serialization_format=serialization_format, 53 | ) 54 | verified = sdjwt_at_verifier.get_verified_payload() 55 | 56 | # We here expect that the output claims are the same as the input claims 57 | expected_claims = remove_sdobj_wrappers(testcase["user_claims"]) 58 | expected_claims["iss"] = settings["identifiers"]["issuer"] 59 | 60 | if testcase.get("key_binding", False): 61 | expected_claims["cnf"] = { 62 | "jwk": demo_keys["holder_key"].export_public(as_dict=True) 63 | } 64 | 65 | assert verified == expected_claims 66 | 67 | # We don't compare header parameters for JSON Serialization for now 68 | if serialization_format != "compact": 69 | return 70 | 71 | expected_header_parameters = { 72 | "alg": testcase.get("sign_alg", "ES256"), 73 | "typ": "testcase+sd-jwt" 74 | } 75 | expected_header_parameters.update(extra_header_parameters) 76 | 77 | assert sdjwt_header_parameters == expected_header_parameters 78 | -------------------------------------------------------------------------------- /tests/test_e2e_testcases.py: -------------------------------------------------------------------------------- 1 | from sd_jwt import __version__ 2 | from sd_jwt.holder import SDJWTHolder 3 | from sd_jwt.issuer import SDJWTIssuer 4 | from sd_jwt.utils.demo_utils import get_jwk 5 | from sd_jwt.verifier import SDJWTVerifier 6 | 7 | 8 | def test_e2e(testcase, settings): 9 | settings.update(testcase.get("settings_override", {})) 10 | seed = settings["random_seed"] 11 | demo_keys = get_jwk(settings["key_settings"], True, seed) 12 | use_decoys = testcase.get("add_decoy_claims", False) 13 | serialization_format = testcase.get("serialization_format", "compact") 14 | 15 | extra_header_parameters = {"typ": "testcase+sd-jwt"} 16 | extra_header_parameters.update(testcase.get("extra_header_parameters", {})) 17 | 18 | # Issuer: Produce SD-JWT and issuance format for selected example 19 | 20 | user_claims = {"iss": settings["identifiers"]["issuer"]} 21 | user_claims.update(testcase["user_claims"]) 22 | 23 | SDJWTIssuer.unsafe_randomness = True 24 | sdjwt_at_issuer = SDJWTIssuer( 25 | user_claims, 26 | demo_keys["issuer_keys"], 27 | demo_keys["holder_key"] if testcase.get("key_binding", False) else None, 28 | add_decoy_claims=use_decoys, 29 | serialization_format=serialization_format, 30 | extra_header_parameters=extra_header_parameters, 31 | ) 32 | 33 | output_issuance = sdjwt_at_issuer.sd_jwt_issuance 34 | 35 | # Holder 36 | 37 | sdjwt_at_holder = SDJWTHolder( 38 | output_issuance, 39 | serialization_format=serialization_format, 40 | ) 41 | sdjwt_at_holder.create_presentation( 42 | testcase["holder_disclosed_claims"], 43 | settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, 44 | ( 45 | settings["identifiers"]["verifier"] 46 | if testcase.get("key_binding", False) 47 | else None 48 | ), 49 | demo_keys["holder_key"] if testcase.get("key_binding", False) else None, 50 | ) 51 | 52 | output_holder = sdjwt_at_holder.sd_jwt_presentation 53 | 54 | # Verifier 55 | sdjwt_header_parameters = {} 56 | 57 | def cb_get_issuer_key(issuer, header_parameters): 58 | if type(header_parameters) == dict: 59 | sdjwt_header_parameters.update(header_parameters) 60 | return demo_keys["issuer_public_keys"] 61 | 62 | sdjwt_at_verifier = SDJWTVerifier( 63 | output_holder, 64 | cb_get_issuer_key, 65 | ( 66 | settings["identifiers"]["verifier"] 67 | if testcase.get("key_binding", False) 68 | else None 69 | ), 70 | settings["key_binding_nonce"] if testcase.get("key_binding", False) else None, 71 | serialization_format=serialization_format, 72 | ) 73 | verified = sdjwt_at_verifier.get_verified_payload() 74 | 75 | expected_claims = testcase["expect_verified_user_claims"] 76 | expected_claims["iss"] = settings["identifiers"]["issuer"] 77 | 78 | if testcase.get("key_binding", False): 79 | expected_claims["cnf"] = { 80 | "jwk": demo_keys["holder_key"].export_public(as_dict=True) 81 | } 82 | 83 | assert verified == expected_claims 84 | 85 | # We don't compare header parameters for JSON Serialization for now 86 | if serialization_format != "compact": 87 | return 88 | 89 | expected_header_parameters = { 90 | "alg": testcase.get("sign_alg", "ES256"), 91 | "typ": "testcase+sd-jwt", 92 | } 93 | expected_header_parameters.update(extra_header_parameters) 94 | 95 | assert sdjwt_header_parameters == expected_header_parameters 96 | -------------------------------------------------------------------------------- /tests/test_scripts.py: -------------------------------------------------------------------------------- 1 | # This test attempts to run the script sd-jwt-generate from the command line to see if the script runs without errors. 2 | # Note: The script must be run from the "examples" directory. 3 | 4 | import subprocess 5 | 6 | 7 | def test_generate_py(): 8 | # Run the script 9 | result = subprocess.run( 10 | ["sd-jwt-generate", "example"], 11 | stdout=subprocess.PIPE, 12 | stderr=subprocess.PIPE, 13 | cwd="examples", 14 | ) 15 | 16 | # Check that the script ran without errors 17 | assert result.returncode == 0 18 | assert result.stderr == b"" 19 | 20 | 21 | # Same as above, but run in "testcase" mode from "testcases" directory 22 | def test_generate_py_testcase(): 23 | # Run the script 24 | result = subprocess.run( 25 | ["sd-jwt-generate", "testcase"], 26 | stdout=subprocess.PIPE, 27 | stderr=subprocess.PIPE, 28 | cwd="tests/testcases", 29 | ) 30 | 31 | # Check that the script ran without errors 32 | assert result.returncode == 0 33 | assert result.stderr == b"" 34 | -------------------------------------------------------------------------------- /tests/test_utils_yaml_specification.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import io 3 | 4 | from sd_jwt import __version__ 5 | from sd_jwt.utils.yaml_specification import _yaml_load_specification 6 | from sd_jwt.common import SDObj 7 | 8 | YAML_TESTCASES = [ 9 | """ 10 | user_claims: 11 | is_over: 12 | !sd "13": True 13 | !sd "18": False 14 | !sd "21": False 15 | """, 16 | """ 17 | yaml_parsing: | 18 | Multiline text 19 | is also supported 20 | """ 21 | ] 22 | 23 | YAML_TESTCASES_EXPECTED = [ 24 | { 25 | "user_claims": { 26 | "is_over": { 27 | SDObj("13"): True, 28 | SDObj("18"): False, 29 | SDObj("21"): False, 30 | } 31 | } 32 | }, 33 | { 34 | "yaml_parsing": "Multiline text\nis also supported\n" 35 | } 36 | ] 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "yaml_testcase,expected", zip(YAML_TESTCASES, YAML_TESTCASES_EXPECTED) 41 | ) 42 | def test_parsing_yaml(yaml_testcase, expected): 43 | # load_yaml_specification expects a file-like object, so we wrap the string in an io.StringIO 44 | 45 | yaml_testcase = io.StringIO(yaml_testcase) 46 | result = _yaml_load_specification(yaml_testcase) 47 | assert result == expected 48 | -------------------------------------------------------------------------------- /tests/testcases/array_data_types/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | data_types: 3 | - !sd null 4 | - !sd 42 5 | - !sd 3.14 6 | - !sd "foo" 7 | - !sd True 8 | - !sd ["Test"] 9 | - !sd {"foo": "bar"} 10 | 11 | holder_disclosed_claims: 12 | data_types: 13 | - True 14 | - True 15 | - True 16 | - True 17 | - True 18 | - True 19 | - True 20 | 21 | expect_verified_user_claims: 22 | data_types: 23 | - null 24 | - 42 25 | - 3.14 26 | - "foo" 27 | - True 28 | - ["Test"] 29 | - {"foo": "bar"} 30 | -------------------------------------------------------------------------------- /tests/testcases/array_full_sd/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | # Make all elements SD 3 | is_over: 4 | !sd "13": True 5 | !sd "18": False 6 | !sd "21": False 7 | 8 | holder_disclosed_claims: 9 | is_over: 10 | "21": False 11 | "18": True 12 | "13": False 13 | 14 | expect_verified_user_claims: 15 | is_over: 16 | "18": False 17 | 18 | -------------------------------------------------------------------------------- /tests/testcases/array_in_sd/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sd_array: 3 | - 32 4 | - 23 5 | 6 | holder_disclosed_claims: 7 | sd_array: True 8 | 9 | expect_verified_user_claims: 10 | sd_array: [32, 23] 11 | 12 | -------------------------------------------------------------------------------- /tests/testcases/array_nested_in_plain/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | nested_array: [[!sd "foo", !sd "bar"], [!sd "baz", !sd "qux"]] 3 | 4 | holder_disclosed_claims: 5 | nested_array: [[True, False], [False, True]] 6 | 7 | expect_verified_user_claims: 8 | nested_array: [["foo"], ["qux"]] 9 | -------------------------------------------------------------------------------- /tests/testcases/array_none_disclosed/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | # Make all elements SD 3 | is_over: 4 | !sd "13": False 5 | !sd "18": True 6 | !sd "21": False 7 | 8 | holder_disclosed_claims: 9 | is_over: 10 | "21": False 11 | 12 | expect_verified_user_claims: 13 | is_over: {} 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/testcases/array_of_nulls/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | null_values: 3 | - null 4 | - !sd null 5 | - !sd null 6 | - null 7 | 8 | 9 | holder_disclosed_claims: {} 10 | 11 | expect_verified_user_claims: 12 | null_values: 13 | - null 14 | - null 15 | 16 | -------------------------------------------------------------------------------- /tests/testcases/array_of_objects/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | addresses: 3 | - street: "123 Main St" 4 | city: "Anytown" 5 | state: "NY" 6 | zip: "12345" 7 | type: "main_address" 8 | 9 | - !sd 10 | street: "456 Main St" 11 | city: "Anytown" 12 | state: "NY" 13 | zip: "12345" 14 | type: "secondary_address" 15 | 16 | array_with_one_sd_object: 17 | - !sd foo: "bar" 18 | 19 | holder_disclosed_claims: 20 | addresses: 21 | - null 22 | - True 23 | 24 | array_with_one_sd_object: 25 | - foo: True 26 | 27 | expect_verified_user_claims: 28 | addresses: 29 | - street: "123 Main St" 30 | city: "Anytown" 31 | state: "NY" 32 | zip: "12345" 33 | type: "main_address" 34 | 35 | - street: "456 Main St" 36 | city: "Anytown" 37 | state: "NY" 38 | zip: "12345" 39 | type: "secondary_address" 40 | 41 | array_with_one_sd_object: 42 | - foo: "bar" -------------------------------------------------------------------------------- /tests/testcases/array_of_scalars/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | # Make only first two elements SD - this array will have three elements in the resulting SD-JWT, first two hidden 3 | nationalities: 4 | - !sd "US" 5 | - !sd "CA" 6 | - "DE" 7 | 8 | holder_disclosed_claims: 9 | nationalities: 10 | - False 11 | - True 12 | 13 | expect_verified_user_claims: 14 | nationalities: 15 | - "CA" 16 | - "DE" 17 | -------------------------------------------------------------------------------- /tests/testcases/array_recursive_sd/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | array_with_recursive_sd: 3 | - boring 4 | - !sd 5 | foo: "bar" 6 | !sd baz: 7 | qux: "quux" 8 | - [!sd "foo", !sd "bar"] 9 | 10 | test2: [!sd "foo", !sd "bar"] 11 | 12 | holder_disclosed_claims: {} 13 | 14 | expect_verified_user_claims: 15 | array_with_recursive_sd: 16 | - boring 17 | - [] 18 | 19 | test2: [] 20 | -------------------------------------------------------------------------------- /tests/testcases/array_recursive_sd_some_disclosed/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | array_with_recursive_sd: 3 | - boring 4 | - foo: "bar" 5 | !sd baz: 6 | qux: "quux" 7 | - [!sd "foo", !sd "bar"] 8 | 9 | test2: [!sd "foo", !sd "bar"] 10 | 11 | holder_disclosed_claims: 12 | array_with_recursive_sd: 13 | - None 14 | - baz: True 15 | - [False, True] 16 | 17 | test2: [True, True] 18 | 19 | expect_verified_user_claims: 20 | array_with_recursive_sd: 21 | - boring 22 | - foo: bar 23 | baz: 24 | qux: quux 25 | - ["bar"] 26 | 27 | test2: ["foo", "bar"] 28 | -------------------------------------------------------------------------------- /tests/testcases/header_mod/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sub: john_doe_42 3 | !sd given_name: John 4 | !sd family_name: Doe 5 | !sd email: johndoe@example.com 6 | !sd phone_number: +1-202-555-0101 7 | !sd address: 8 | street_address: 123 Main St 9 | locality: Anytown 10 | region: Anystate 11 | country: US 12 | !sd birthdate: "1940-01-01" 13 | 14 | holder_disclosed_claims: 15 | given_name: true 16 | family_name: true 17 | address: true 18 | 19 | extra_header_parameters: 20 | kid: 42 21 | 22 | expect_verified_user_claims: 23 | given_name: John 24 | family_name: Doe 25 | address: 26 | street_address: 123 Main St 27 | locality: Anytown 28 | region: Anystate 29 | country: US 30 | 31 | key_binding: True 32 | 33 | serialization_format: compact 34 | -------------------------------------------------------------------------------- /tests/testcases/json_serialization_flattened/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sub: john_doe_42 3 | !sd given_name: John 4 | !sd family_name: Doe 5 | !sd email: johndoe@example.com 6 | !sd phone_number: +1-202-555-0101 7 | !sd address: 8 | street_address: 123 Main St 9 | locality: Anytown 10 | region: Anystate 11 | country: US 12 | !sd birthdate: "1940-01-01" 13 | 14 | holder_disclosed_claims: 15 | given_name: true 16 | family_name: true 17 | address: true 18 | 19 | expect_verified_user_claims: 20 | given_name: John 21 | family_name: Doe 22 | address: 23 | street_address: 123 Main St 24 | locality: Anytown 25 | region: Anystate 26 | country: US 27 | 28 | key_binding: True 29 | 30 | serialization_format: json 31 | 32 | settings_override: 33 | issuer_keys: 34 | - kty: EC 35 | d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g 36 | crv: P-256 37 | x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ 38 | y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 39 | kid: "issuer-key-1" -------------------------------------------------------------------------------- /tests/testcases/json_serialization_general/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sub: john_doe_42 3 | !sd given_name: John 4 | !sd family_name: Doe 5 | !sd email: johndoe@example.com 6 | !sd phone_number: +1-202-555-0101 7 | !sd address: 8 | street_address: 123 Main St 9 | locality: Anytown 10 | region: Anystate 11 | country: US 12 | !sd birthdate: "1940-01-01" 13 | 14 | holder_disclosed_claims: 15 | given_name: true 16 | family_name: true 17 | address: true 18 | 19 | expect_verified_user_claims: 20 | given_name: John 21 | family_name: Doe 22 | address: 23 | street_address: 123 Main St 24 | locality: Anytown 25 | region: Anystate 26 | country: US 27 | 28 | key_binding: True 29 | 30 | serialization_format: json 31 | 32 | settings_override: 33 | key_settings: 34 | key_size: 256 35 | kty: EC 36 | issuer_keys: 37 | - kty: EC 38 | d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g 39 | crv: P-256 40 | x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ 41 | y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 42 | kid: issuer-key-1 43 | - kty: EC 44 | crv: P-256 45 | d: WsGosxrp0XK7VEviPL9xBm3fBb7Xys2vLhPGhESNoXY 46 | x: bN-hp3IN0GZB3OlaQnHDPhY4nZsZbQyo4wY-y1NWCvA 47 | y: vaSsH5jt9zt3aQvTvrSaFYLyjPG9Ug-2vntoNXlCbVU 48 | kid: issuer-key-2 49 | 50 | holder_key: 51 | kty: EC 52 | d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I 53 | crv: P-256 54 | x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc 55 | y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ 56 | -------------------------------------------------------------------------------- /tests/testcases/key_binding/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | !sd sub: john_doe_42 3 | !sd given_name: John 4 | !sd family_name: Doe 5 | !sd email: johndoe@example.com 6 | !sd phone_number: +1-202-555-0101 7 | !sd address: 8 | street_address: 123 Main St 9 | locality: Anytown 10 | region: Anystate 11 | country: US 12 | !sd birthdate: "1940-01-01" 13 | 14 | holder_disclosed_claims: 15 | given_name: true 16 | family_name: true 17 | address: true 18 | 19 | expect_verified_user_claims: 20 | given_name: John 21 | family_name: Doe 22 | address: 23 | street_address: 123 Main St 24 | locality: Anytown 25 | region: Anystate 26 | country: US 27 | 28 | key_binding: True 29 | 30 | serialization_format: compact -------------------------------------------------------------------------------- /tests/testcases/no_sd/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | recursive: 3 | - boring 4 | - foo: "bar" 5 | baz: 6 | qux: "quux" 7 | - ["foo", "bar"] 8 | 9 | test2: ["foo", "bar"] 10 | 11 | holder_disclosed_claims: {} 12 | 13 | expect_verified_user_claims: 14 | recursive: 15 | - boring 16 | - foo: "bar" 17 | baz: 18 | qux: "quux" 19 | - ["foo", "bar"] 20 | test2: ["foo", "bar"] 21 | -------------------------------------------------------------------------------- /tests/testcases/object_data_types/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | value_data_types: 3 | !sd test_null: null 4 | !sd test_int: 42 5 | !sd test_float: 3.14 6 | !sd test_str: "foo" 7 | !sd test_bool: True 8 | !sd test_arr: ["Test"] 9 | !sd test_object: { "foo": "bar" } 10 | 11 | key_data_types: # implementations parsing keys as anything other than strings are not compliant 12 | !sd "null": key_null 13 | !sd "42": key_int 14 | !sd "3.14": key_float 15 | !sd "foo": key_str 16 | !sd "True": key_bool 17 | !sd '["Test"]': key_arr 18 | !sd '{"foo": "bar"}': key_object 19 | 20 | 21 | holder_disclosed_claims: 22 | value_data_types: 23 | test_null: True 24 | test_int: True 25 | test_float: True 26 | test_str: True 27 | test_bool: True 28 | test_arr: True 29 | test_object: True 30 | 31 | key_data_types: 32 | "null": True 33 | "42": True 34 | "3.14": True 35 | "foo": True 36 | "True": True 37 | '["Test"]': True 38 | '{"foo": "bar"}': True 39 | 40 | expect_verified_user_claims: 41 | value_data_types: 42 | test_null: null 43 | test_int: 42 44 | test_float: 3.14 45 | test_str: "foo" 46 | test_bool: True 47 | test_arr: ["Test"] 48 | test_object: { "foo": "bar" } 49 | 50 | key_data_types: 51 | "null": "key_null" 52 | "42": "key_int" 53 | "3.14": "key_float" 54 | "foo": "key_str" 55 | "True": "key_bool" 56 | '["Test"]': "key_arr" 57 | '{"foo": "bar"}': "key_object" 58 | 59 | -------------------------------------------------------------------------------- /tests/testcases/recursions/specification.yml: -------------------------------------------------------------------------------- 1 | user_claims: 2 | foo: 3 | - !sd one 4 | - !sd two 5 | 6 | bar: 7 | !sd red: 1 8 | !sd green: 2 9 | 10 | qux: 11 | - !sd 12 | - !sd blue 13 | - !sd yellow 14 | 15 | baz: 16 | - !sd 17 | - !sd orange 18 | - !sd purple 19 | - !sd 20 | - !sd black 21 | - !sd white 22 | 23 | animals: 24 | !sd snake: 25 | !sd name: python 26 | !sd age: 10 27 | !sd bird: 28 | !sd name: eagle 29 | !sd age: 20 30 | 31 | holder_disclosed_claims: 32 | foo: 33 | - False 34 | - True 35 | 36 | bar: 37 | red: False 38 | green: True 39 | 40 | qux: 41 | - [False, True] 42 | 43 | baz: 44 | - [False, True] 45 | - [True, True] 46 | 47 | animals: 48 | snake: 49 | name: False 50 | age: True 51 | bird: 52 | name: False 53 | age: True 54 | 55 | expect_verified_user_claims: 56 | foo: 57 | - two 58 | 59 | bar: 60 | green: 2 61 | 62 | qux: 63 | - [yellow] 64 | 65 | baz: 66 | - [purple] 67 | - [black, white] 68 | 69 | animals: 70 | snake: 71 | age: 10 72 | bird: 73 | age: 20 74 | -------------------------------------------------------------------------------- /tests/testcases/settings.yml: -------------------------------------------------------------------------------- 1 | identifiers: 2 | issuer: "https://example.com/issuer" 3 | verifier: "https://example.com/verifier" 4 | 5 | key_settings: 6 | key_size: 256 7 | 8 | kty: EC 9 | 10 | issuer_keys: 11 | - kty: EC 12 | d: Ur2bNKuBPOrAaxsRnbSH6hIhmNTxSGXshDSUD1a1y7g 13 | crv: P-256 14 | x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ 15 | y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 16 | 17 | holder_key: 18 | kty: EC 19 | d: 5K5SCos8zf9zRemGGUl6yfok-_NiiryNZsvANWMhF-I 20 | crv: P-256 21 | x: TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc 22 | y: ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ 23 | 24 | key_binding_nonce: "1234567890" 25 | 26 | expiry_seconds: 86400000 # 1000 days 27 | 28 | random_seed: 0 29 | 30 | iat: 1683000000 # Tue May 02 2023 04:00:00 GMT+0000 31 | exp: 1883000000 # Sat Sep 01 2029 23:33:20 GMT+0000 32 | --------------------------------------------------------------------------------