├── .dockerignore
├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── kotlinc.xml
├── uiDesigner.xml
└── vcs.xml
├── CODEOWNERS
├── Dockerfile
├── LICENSE
├── README.md
├── build.gradle.kts
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
├── main
└── kotlin
│ └── org
│ └── sd_jwt
│ ├── KeyBasedSdJwtSigner.kt
│ ├── Main.kt
│ ├── SdJwt.kt
│ ├── SdJwtSigner.kt
│ ├── SdJwtVerifier.kt
│ └── TrustedIssuersSdJwtVerifier.kt
└── test
└── kotlin
└── org
└── sd_jwt
├── AdvancedTest.kt
├── Debugging.kt
├── SdJwtKtJSONClaimsTest.kt
├── SdJwtKtTest.kt
└── Utils.kt
/.dockerignore:
--------------------------------------------------------------------------------
1 | .gradle/
2 | .idea/
3 | .git/
4 | build/
5 | README.md
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea/shelf
3 | /confluence/target
4 | /dependencies/repo
5 | /android.tests.dependencies
6 | /dependencies/android.tests.dependencies
7 | /dist
8 | /local
9 | /gh-pages
10 | /ideaSDK
11 | /clionSDK
12 | /android-studio/sdk
13 | out/
14 | /tmp
15 | /intellij
16 | workspace.xml
17 | *.versionsBackup
18 | /idea/testData/debugger/tinyApp/classes*
19 | /jps-plugin/testData/kannotator
20 | /js/js.translator/testData/out/
21 | /js/js.translator/testData/out-min/
22 | /js/js.translator/testData/out-pir/
23 | .gradle/
24 | build/
25 | !**/src/**/build
26 | !**/test/**/build
27 | *.iml
28 | !**/testData/**/*.iml
29 | .idea/remote-targets.xml
30 | .idea/libraries/Gradle*.xml
31 | .idea/libraries/Maven*.xml
32 | .idea/artifacts/PILL_*.xml
33 | .idea/artifacts/KotlinPlugin.xml
34 | .idea/modules
35 | .idea/runConfigurations/JPS_*.xml
36 | .idea/runConfigurations/PILL_*.xml
37 | .idea/runConfigurations/_FP_*.xml
38 | .idea/runConfigurations/_MT_*.xml
39 | .idea/libraries
40 | .idea/modules.xml
41 | .idea/gradle.xml
42 | .idea/compiler.xml
43 | .idea/inspectionProfiles/profiles_settings.xml
44 | .idea/.name
45 | .idea/artifacts/dist_auto_*
46 | .idea/artifacts/dist.xml
47 | .idea/artifacts/ideaPlugin.xml
48 | .idea/artifacts/kotlinc.xml
49 | .idea/artifacts/kotlin_compiler_jar.xml
50 | .idea/artifacts/kotlin_plugin_jar.xml
51 | .idea/artifacts/kotlin_jps_plugin_jar.xml
52 | .idea/artifacts/kotlin_daemon_client_jar.xml
53 | .idea/artifacts/kotlin_imports_dumper_compiler_plugin_jar.xml
54 | .idea/artifacts/kotlin_main_kts_jar.xml
55 | .idea/artifacts/kotlin_compiler_client_embeddable_jar.xml
56 | .idea/artifacts/kotlin_reflect_jar.xml
57 | .idea/artifacts/kotlin_stdlib_js_ir_*
58 | .idea/artifacts/kotlin_test_js_ir_*
59 | .idea/artifacts/kotlin_stdlib_wasm_*
60 | .idea/artifacts/kotlinx_atomicfu_runtime_*
61 | .idea/artifacts/kotlinx_cli_jvm_*
62 | .idea/jarRepositories.xml
63 | .idea/csv-plugin.xml
64 | .idea/libraries-with-intellij-classes.xml
65 | .idea/misc.xml
66 | node_modules/
67 | .rpt2_cache/
68 | libraries/tools/kotlin-test-js-runner/lib/
69 | local.properties
70 | buildSrcTmp/
71 | distTmp/
72 | outTmp/
73 | /test.output
74 | /kotlin-native/dist
75 | kotlin-ide/
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/uiDesigner.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | -
6 |
7 |
8 | -
9 |
10 |
11 | -
12 |
13 |
14 | -
15 |
16 |
17 | -
18 |
19 |
20 |
21 |
22 |
23 | -
24 |
25 |
26 |
27 |
28 |
29 | -
30 |
31 |
32 |
33 |
34 |
35 | -
36 |
37 |
38 |
39 |
40 |
41 | -
42 |
43 |
44 |
45 |
46 | -
47 |
48 |
49 |
50 |
51 | -
52 |
53 |
54 |
55 |
56 | -
57 |
58 |
59 |
60 |
61 | -
62 |
63 |
64 |
65 |
66 | -
67 |
68 |
69 |
70 |
71 | -
72 |
73 |
74 | -
75 |
76 |
77 |
78 |
79 | -
80 |
81 |
82 |
83 |
84 | -
85 |
86 |
87 |
88 |
89 | -
90 |
91 |
92 |
93 |
94 | -
95 |
96 |
97 |
98 |
99 | -
100 |
101 |
102 | -
103 |
104 |
105 | -
106 |
107 |
108 | -
109 |
110 |
111 | -
112 |
113 |
114 |
115 |
116 | -
117 |
118 |
119 | -
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @fabian-hk
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:11-slim
2 |
3 | RUN apt update && apt install -y openjdk-17-jdk
4 |
5 | WORKDIR /sd-jwt
6 | COPY . .
7 |
8 | CMD ./gradlew test --tests SdJwtKtTest -i -PossrhUsername= -PossrhPassword=
9 |
--------------------------------------------------------------------------------
/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 2023 Fabian Hauck
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 Implementation in Kotlin
2 |
3 | ---
4 |
5 | **Important note:** This project is no longer maintained.
6 | We recommend using the [eudi-lib-jvm-sdjwt-kt](https://github.com/eu-digital-identity-wallet/eudi-lib-jvm-sdjwt-kt)
7 | library instead. If you are interested in maintaining this project,
8 | please contact [Fabian Hauck](mailto:contact@fabianhauck.de).
9 |
10 | ---
11 |
12 | This is a Kotlin implementation of the [Selective Disclosure for JWTs](https://github.com/oauthstuff/draft-selective-disclosure-jwt)
13 | spec using the [Nimbus JOSE + JWT](https://connect2id.com/products/nimbus-jose-jwt)
14 | library.
15 |
16 | Up to date with draft version: [**04**](https://drafts.oauth.net/oauth-selective-disclosure-jwt/draft-ietf-oauth-selective-disclosure-jwt.html)
17 |
18 | ## Checking Out the Implementation
19 |
20 | In the [Debugging.kt](src/test/kotlin/org/sd_jwt/Debugging.kt) file
21 | there are examples that show how the library can be used
22 | on the issuance, wallet and verifier side.
23 |
24 | ### Running the Examples
25 |
26 | #### First Possibility
27 |
28 | If you have Docker installed you can simply run:
29 |
30 | 1. ``docker build -t sd-jwt .``
31 | 2. ``docker run -it --rm sd-jwt``
32 |
33 | #### Second Possibility (Linux)
34 |
35 | 1. Install Java version 17 or newer (e.g. ``sudo apt install -y openjdk-17-jdk``)
36 | 2. Run tests with the gradle wrapper: ``./gradlew test --tests SdJwtKtTest -i -PossrhUsername= -PossrhPassword=``
37 |
38 | ## Import into Gradle Project
39 |
40 | **Note: The current version is not yet available on Maven Central.
41 | It will probably be published under the version 0.1.0**
42 |
43 | **The current SNAPSHOT version can be found in [this repository](https://s01.oss.sonatype.org/content/repositories/snapshots/org/sd-jwt/sd-jwt-kotlin/).**
44 |
45 | *build.gradle*
46 | ```groovy
47 | plugins {
48 | /* ... */
49 | id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10'
50 | }
51 |
52 | dependencies {
53 | /* ... */
54 | implementation 'org.sd-jwt:sd-jwt-kotlin:0.0.0'
55 |
56 | // https://mvnrepository.com/artifact/com.nimbusds/nimbus-jose-jwt
57 | implementation("com.nimbusds:nimbus-jose-jwt:9.30.1")
58 | // For ED25519 key pairs
59 | implementation("com.google.crypto.tink:tink:1.9.0")
60 |
61 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
62 | }
63 | ```
64 |
65 | ## Simple Library Usage
66 |
67 | ### Initialization
68 |
69 | First you need to define your credential as a
70 | [kotlinx serializable](https://github.com/Kotlin/kotlinx.serialization)
71 | data class.
72 |
73 | ```kotlin
74 | @Serializable
75 | private data class SimpleTestCredential(
76 | val iss: String,
77 | @SerialName("given_name") val givenName: String? = null,
78 | @SerialName("family_name") val familyName: String? = null,
79 | val email: String? = null,
80 | val b: Boolean? = null,
81 | val age: Int? = null
82 | )
83 | ```
84 |
85 | Then you need a few variables to get started.
86 |
87 | ```kotlin
88 | val issuer = "http://issuer.example.com"
89 |
90 | val issuerKeyJson = """{"kty":"OKP","d":"Pp1foKt6rJAvx0igrBEfOgrT0dgMVQDHmgJZbm2h518","crv":"Ed25519","kid":"IssuerKey","x":"1NYF4EFS2Ov9hqt35fVt2J-dktLV29hs8UFjxbOXnho"}"""
91 | val issuerKey = OctetKeyPair.parse(issuerKeyJson)
92 |
93 | val trustedIssuers = mutableMapOf(issuer to issuerKey.toPublicJWK().toJSONString())
94 | ```
95 |
96 | ### Issuer Creating the Credential
97 |
98 | ```kotlin
99 | val claims = SimpleTestCredential(iss = issuer, "Alice", "Wonderland", "alice@example.com", false, 21)
100 | val discloseStructure = SimpleTestCredential(iss = "") // This is required so that 'iss' is not hidden
101 | val credential = createCredential(claims, issuerKey, discloseStructure = discloseStructure)
102 | ```
103 |
104 | ### Wallet Creating the Presentation
105 |
106 | ```kotlin
107 | val releaseClaims = SimpleTestCredential(iss = "", givenName = "", email = "", age = 0) // Non-null claims will be revealed
108 | val presentation = createPresentation(credential, releaseClaims)
109 | ```
110 |
111 | ### Verifier Parsing and Verifying the Credential
112 |
113 | ```kotlin
114 | val verifiedSimpleTestCredential = verifyPresentation(
115 | presentation,
116 | trustedIssuers,
117 | verifyHolderBinding = false
118 | )
119 | ```
120 |
121 | ## Advanced Library Usage
122 |
123 | This code shows how to
124 | - use holder binding
125 | - create a structured SD-JWT
126 | - create recursively disclosable claims (add HIDE_NAME to the @SerialName annotation)
127 | - add custom header fields to the SD-JWT
128 |
129 | ```kotlin
130 | @Serializable
131 | data class CredentialSubject(
132 | @SerialName("given_name") val givenName: String? = null,
133 | @SerialName("family_name") val familyName: String? = null,
134 | val email: String? = null
135 | )
136 |
137 | @Serializable
138 | data class EmailCredential(
139 | val type: String,
140 | val iat: Long,
141 | val exp: Long,
142 | val iss: String,
143 | // Make this object recursively discloseable
144 | @SerialName(HIDE_NAME + "credentialSubject") val credentialSubject: CredentialSubject? = null
145 | )
146 |
147 | val issuerKey = ECKeyGenerator(Curve.P_256)
148 | .keyID("Issuer")
149 | .generate()
150 |
151 | val holderKey = ECKeyGenerator(Curve.P_256)
152 | .keyID("Holder")
153 | .generate()
154 |
155 | val issuer = "did:jwk:${b64Encoder(issuerKey.toPublicJWK().toJSONString())}"
156 |
157 | val trustedIssuers = mapOf(issuer to issuerKey.toPublicJWK().toJSONString())
158 |
159 | val userClaims = EmailCredential(
160 | type = "VerifiedEMail",
161 | iat = Date.from(Instant.now()).time / 1000,
162 | exp = Date.from(Instant.now().plusSeconds(3600 * 48)).time / 1000,
163 | iss = issuer,
164 | credentialSubject = CredentialSubject(
165 | givenName = "Alice",
166 | familyName = "Wonderland",
167 | email = "alice@example.com"
168 | )
169 | )
170 |
171 | // Each non-null variable will be separately disclosed.
172 | // Primitive types that are not null will be in plain text in the SD-JWT.
173 | val discloseStructure = EmailCredential(type = "", iat = 0, exp = 0, iss = "", credentialSubject = CredentialSubject())
174 |
175 | // Add custom header fields to the SD-JWT
176 | val header = SdJwtHeader(JOSEObjectType("vc+sd-jwt"), "credential-claims-set+json")
177 |
178 | /***************** Create Credential *****************/
179 | val credential = createCredential(userClaims, issuerKey, holderKey.toPublicJWK(), discloseStructure, sdJwtHeader = header)
180 |
181 | /**************** Create Presentation ****************/
182 | val releaseClaims = EmailCredential(type = "", iat = 0, exp = 0, iss = "", credentialSubject = CredentialSubject(email = ""))
183 | val presentation = createPresentation(credential, releaseClaims, "https://nextcloud.example.com", "1234", holderKey)
184 |
185 | /**************** Verify Presentation ****************/
186 | val verifiedEmailCredential = verifyPresentation(
187 | presentation,
188 | trustedIssuers,
189 | "1234",
190 | "https://nextcloud.example.com",
191 | true
192 | )
193 | ```
194 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | plugins {
4 | kotlin("jvm") version "1.8.22"
5 | kotlin("plugin.serialization") version "1.8.22"
6 | application
7 | `maven-publish`
8 | id("org.jetbrains.dokka") version "1.7.20"
9 | signing
10 | }
11 |
12 | group = "org.sd-jwt"
13 | version = "0.1.0-SNAPSHOT"
14 |
15 | java {
16 | sourceCompatibility = JavaVersion.VERSION_17
17 | }
18 |
19 | dependencies {
20 | testImplementation(kotlin("test"))
21 | implementation(kotlin("stdlib-jdk8"))
22 | //implementation("org.jetbrains.dokka:dokka-gradle-plugin:1.6.20")
23 |
24 | // https://mvnrepository.com/artifact/com.nimbusds/nimbus-jose-jwt
25 | implementation("com.nimbusds:nimbus-jose-jwt:9.31")
26 | // https://mvnrepository.com/artifact/com.google.crypto.tink/tink
27 | // For ED25519 key pairs
28 | implementation("com.google.crypto.tink:tink:1.9.0")
29 |
30 | // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json
31 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
32 |
33 | // https://mvnrepository.com/artifact/org.json/json
34 | implementation("org.json:json:20230227")
35 | }
36 |
37 | tasks.test {
38 | useJUnitPlatform()
39 | }
40 |
41 | tasks.withType {
42 | kotlinOptions.jvmTarget = "17"
43 | }
44 |
45 | application {
46 | mainClass.set("org.sd_jwt.MainKt")
47 | }
48 | repositories {
49 | mavenCentral()
50 | }
51 | val compileKotlin: KotlinCompile by tasks
52 | compileKotlin.kotlinOptions {
53 | jvmTarget = "17"
54 | }
55 | val compileTestKotlin: KotlinCompile by tasks
56 | compileTestKotlin.kotlinOptions {
57 | jvmTarget = "17"
58 | }
59 |
60 | tasks.withType {
61 | manifest {
62 | attributes["Main-Class"] = "org.sd_jwt.MainKt"
63 | }
64 |
65 | // To add all the dependencies
66 | /*from(sourceSets.main.get().output)
67 |
68 | dependsOn(configurations.runtimeClasspath)
69 | from({
70 | configurations.runtimeClasspath.get().filter { it.name.endsWith("jar") }.map { zipTree(it) }
71 | })
72 | duplicatesStrategy = DuplicatesStrategy.INCLUDE*/
73 | }
74 |
75 | // Generates source jar
76 | java {
77 | withSourcesJar()
78 | }
79 |
80 | sourceSets {
81 | main {
82 | java.srcDir("src/main/kotlin")
83 | }
84 | }
85 |
86 | // Create Javadoc jar
87 | java {
88 | withJavadocJar()
89 | }
90 |
91 | val javadocJar = tasks.named("javadocJar") {
92 | from(tasks.named("dokkaJavadoc"))
93 | }
94 |
95 | // Tutorial: https://docs.gradle.org/current/userguide/publishing_maven.html
96 | publishing {
97 | publications {
98 | create("mavenJava") {
99 | pom {
100 | name.set("SD-JWT Kotlin Library")
101 | description.set("SD-JWT Kotlin Library (currently in beta status)")
102 | url.set("https://github.com/IDunion/SD-JWT-Kotlin")
103 | developers {
104 | developer {
105 | id.set("fabian-hk")
106 | name.set("Fabian Hauck")
107 | email.set("contact@fabianhauck.de")
108 | }
109 | }
110 | licenses {
111 | license {
112 | name.set("Apache-2.0")
113 | url.set("https://www.apache.org/licenses/LICENSE-2.0")
114 | }
115 | }
116 | scm {
117 | connection.set("scm:git:git@github.com:IDunion/SD-JWT-Kotlin.git")
118 | developerConnection.set("scm:git:ssh://github.com:IDunion/SD-JWT-Kotlin.git")
119 | url.set("https://github.com/IDunion/SD-JWT-Kotlin")
120 | }
121 | }
122 |
123 | from(components["java"])
124 | }
125 | }
126 | repositories {
127 | maven {
128 | val snapshotUrl = "https://s01.oss.sonatype.org/content/repositories/snapshots/"
129 | val releaseUrl = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
130 | url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotUrl else releaseUrl)
131 | val ossrhUsername: String by properties
132 | val ossrhPassword: String by properties
133 | credentials {
134 | username = ossrhUsername
135 | password = ossrhPassword
136 | }
137 | }
138 | }
139 | }
140 |
141 |
142 | signing {
143 | useGpgCmd()
144 | sign(publishing.publications["mavenJava"])
145 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openwallet-foundation-labs/sd-jwt-kotlin/ba4abdcd447a5f0458ca4862eef179cc4666b716/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
84 |
85 | APP_NAME="Gradle"
86 | APP_BASE_NAME=${0##*/}
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | MAX_FD=$( ulimit -H -n ) ||
147 | warn "Could not query maximum file descriptor limit"
148 | esac
149 | case $MAX_FD in #(
150 | '' | soft) :;; #(
151 | *)
152 | ulimit -n "$MAX_FD" ||
153 | warn "Could not set maximum file descriptor limit to $MAX_FD"
154 | esac
155 | fi
156 |
157 | # Collect all arguments for the java command, stacking in reverse order:
158 | # * args from the command line
159 | # * the main class name
160 | # * -classpath
161 | # * -D...appname settings
162 | # * --module-path (only if needed)
163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
164 |
165 | # For Cygwin or MSYS, switch paths to Windows format before running java
166 | if "$cygwin" || "$msys" ; then
167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
169 |
170 | JAVACMD=$( cygpath --unix "$JAVACMD" )
171 |
172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
173 | for arg do
174 | if
175 | case $arg in #(
176 | -*) false ;; # don't mess with options #(
177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
178 | [ -e "$t" ] ;; #(
179 | *) false ;;
180 | esac
181 | then
182 | arg=$( cygpath --path --ignore --mixed "$arg" )
183 | fi
184 | # Roll the args list around exactly as many times as the number of
185 | # args, so each arg winds up back in the position where it started, but
186 | # possibly modified.
187 | #
188 | # NB: a `for` loop captures its iteration list before it begins, so
189 | # changing the positional parameters here affects neither the number of
190 | # iterations, nor the values presented in `arg`.
191 | shift # remove old arg
192 | set -- "$@" "$arg" # push replacement arg
193 | done
194 | fi
195 |
196 | # Collect all arguments for the java command;
197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
198 | # shell script including quotes and variable substitutions, so put them in
199 | # double quotes to make sure that they get re-expanded; and
200 | # * put everything else in single quotes, so that it's not re-expanded.
201 |
202 | set -- \
203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
204 | -classpath "$CLASSPATH" \
205 | org.gradle.wrapper.GradleWrapperMain \
206 | "$@"
207 |
208 | # Use "xargs" to parse quoted args.
209 | #
210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
211 | #
212 | # In Bash we could simply go:
213 | #
214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
215 | # set -- "${ARGS[@]}" "$@"
216 | #
217 | # but POSIX shell has neither arrays nor command substitution, so instead we
218 | # post-process each arg (as a line of input to sed) to backslash-escape any
219 | # character that might be a shell metacharacter, then use eval to reverse
220 | # that process (while maintaining the separation between arguments), and wrap
221 | # the whole thing up as a single "set" statement.
222 | #
223 | # This will of course break if any of these variables contains a newline or
224 | # an unmatched quote.
225 | #
226 |
227 | eval "set -- $(
228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
229 | xargs -n1 |
230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
231 | tr '\n' ' '
232 | )" '"$@"'
233 |
234 | exec "$JAVACMD" "$@"
235 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 |
2 | rootProject.name = "sd-jwt-kotlin"
3 |
4 |
5 | include(":jvm")
6 |
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/org/sd_jwt/KeyBasedSdJwtSigner.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.JOSEObjectType
4 | import com.nimbusds.jose.JWSAlgorithm
5 | import com.nimbusds.jose.JWSHeader
6 | import com.nimbusds.jose.JWSSigner
7 | import com.nimbusds.jose.crypto.ECDSASigner
8 | import com.nimbusds.jose.crypto.Ed25519Signer
9 | import com.nimbusds.jose.crypto.RSASSASigner
10 | import com.nimbusds.jose.jwk.*
11 |
12 | /**
13 | * Data class for setting the SD-JWT header parameters typ and cty.
14 | * @param type: typ header parameter (example: JOSEObjectType("vc+sd-jwt"))
15 | * @param cty: cty header parameter (example: "credential-claims-set+json")
16 | */
17 | data class SdJwtHeader(val type: JOSEObjectType? = null, val cty: String? = null)
18 |
19 |
20 | /**
21 | * A simple key-based implementation of an SD-JWT signer.
22 | *
23 | * @param key the private JWK for creating the JWSHeader and the JWSSigner.
24 | * @param sdJwtHeader set a value for the SD-JWT header parameters 'typ' and 'cty' (optional).
25 | */
26 | class KeyBasedSdJwtSigner(key: JWK, sdJwtHeader: SdJwtHeader = SdJwtHeader()): SdJwtSigner {
27 | private val signer: JWSSigner
28 | private val publicJWK: JWK
29 | private val header: JWSHeader.Builder
30 |
31 | init {
32 | when (key.keyType) {
33 | KeyType.OKP -> {
34 | signer = Ed25519Signer(key as OctetKeyPair)
35 | header = JWSHeader.Builder(JWSAlgorithm.EdDSA).keyID(key.keyID)
36 | }
37 |
38 | KeyType.RSA -> {
39 | signer = RSASSASigner(key as RSAKey)
40 | header = JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID)
41 | }
42 |
43 | KeyType.EC -> {
44 | signer = ECDSASigner(key as ECKey)
45 | header = JWSHeader.Builder(signer.supportedECDSAAlgorithm()).keyID(key.keyID)
46 | }
47 |
48 | else -> {
49 | throw NotImplementedError("JWK signing algorithm not implemented")
50 | }
51 | }
52 | publicJWK = key.toPublicJWK()
53 |
54 | if (sdJwtHeader.type != null) {
55 | header.type(sdJwtHeader.type)
56 | }
57 | if (sdJwtHeader.cty != null) {
58 | header.contentType(sdJwtHeader.cty)
59 | }
60 | }
61 | override fun sdJwtHeader(): JWSHeader {
62 | return header.build()
63 | }
64 |
65 | override fun jwsSigner(): JWSSigner {
66 | return signer
67 | }
68 |
69 | override fun getPublicJWK(): JWK {
70 | return publicJWK
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/main/kotlin/org/sd_jwt/Main.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | /** @suppress */
4 | fun main(args: Array) {
5 | // TODO add CLI
6 | }
--------------------------------------------------------------------------------
/src/main/kotlin/org/sd_jwt/SdJwt.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.JWSVerifier
4 | import com.nimbusds.jose.PlainHeader
5 | import com.nimbusds.jose.crypto.ECDSAVerifier
6 | import com.nimbusds.jose.crypto.Ed25519Verifier
7 | import com.nimbusds.jose.crypto.RSASSAVerifier
8 | import com.nimbusds.jose.jwk.JWK
9 | import com.nimbusds.jose.jwk.KeyType
10 | import com.nimbusds.jwt.JWTClaimsSet
11 | import com.nimbusds.jwt.PlainJWT
12 | import com.nimbusds.jwt.SignedJWT
13 | import kotlinx.serialization.encodeToString
14 | import kotlinx.serialization.decodeFromString
15 | import kotlinx.serialization.json.Json
16 | import org.json.JSONArray
17 | import org.json.JSONObject
18 | import java.security.MessageDigest
19 | import java.security.SecureRandom
20 | import java.time.Instant
21 | import java.time.LocalDateTime
22 | import java.time.ZoneOffset
23 | import java.util.*
24 | import kotlin.collections.HashMap
25 |
26 | /** @suppress */
27 | val SD_DIGEST_KEY = "_sd"
28 |
29 | /** @suppress */
30 | val DIGEST_ALG_KEY = "_sd_alg"
31 |
32 | /** @suppress */
33 | val HOLDER_BINDING_KEY = "cnf"
34 |
35 | /** @suppress */
36 | val SEPARATOR = "~"
37 | val DECOY_MIN = 2
38 | val DECOY_MAX = 5
39 |
40 | /** @suppress */
41 | const val HIDE_NAME = "59af18d6-03b8-4349-89a9-3710d51477e9:"
42 |
43 | /**
44 | * @suppress
45 | * This method is not for API users.
46 | */
47 | private fun createHash(value: String): String {
48 | val hashFunction = MessageDigest.getInstance("SHA-256")
49 | val messageDigest = hashFunction.digest(value.toByteArray(Charsets.UTF_8))
50 | return b64Encoder(messageDigest)
51 | }
52 |
53 | /**
54 | * @suppress
55 | * This method is not for API users.
56 | */
57 | private fun generateSalt(): String {
58 | val secureRandom = SecureRandom()
59 | val randomness = ByteArray(16)
60 | secureRandom.nextBytes(randomness)
61 | return b64Encoder(randomness)
62 | }
63 |
64 | /**
65 | * @suppress
66 | * This method is not for API users.
67 | */
68 | private fun createSdClaimEntry(key: String, value: Any, disclosures: MutableList): String {
69 | val disclosure = JSONArray()
70 | .put(generateSalt())
71 | .put(key)
72 | .put(value)
73 | .toString()
74 | val disclosureB64 = b64Encoder(disclosure)
75 | disclosures.add(disclosureB64)
76 | return createHash(disclosureB64)
77 | }
78 |
79 | /**
80 | * @suppress
81 | * This method is not for API users.
82 | */
83 | fun createSdClaims(
84 | userClaims: Any,
85 | discloseStructure: Any,
86 | disclosures: MutableList,
87 | decoy: Boolean
88 | ): Any {
89 | if (userClaims is JSONObject && discloseStructure is JSONObject) {
90 | val secureRandom = SecureRandom()
91 | val sdClaims = JSONObject()
92 | val sdDigest = mutableListOf()
93 | for (key in userClaims.keys()) {
94 | if (key.startsWith(HIDE_NAME)) {
95 | val disclosureContent =
96 | createSdClaims(userClaims.get(key), discloseStructure.get(key), disclosures, decoy)
97 | val strippedKey = key.replace(HIDE_NAME, "")
98 | sdDigest.add(createSdClaimEntry(strippedKey, disclosureContent, disclosures))
99 | } else if (discloseStructure.has(key)) {
100 | sdClaims.put(
101 | key,
102 | createSdClaims(userClaims.get(key), discloseStructure.get(key), disclosures, decoy)
103 | )
104 | } else {
105 | sdDigest.add(createSdClaimEntry(key, userClaims.get(key), disclosures))
106 | }
107 | }
108 | if (sdDigest.isNotEmpty() && decoy) {
109 | for (i in 0 until secureRandom.nextInt(DECOY_MIN, DECOY_MAX)) {
110 | sdDigest.add(createHash(generateSalt()))
111 | }
112 | }
113 | if (sdDigest.isNotEmpty()) {
114 | sdDigest.shuffle(secureRandom)
115 | sdClaims.put(SD_DIGEST_KEY, sdDigest)
116 | }
117 | return sdClaims
118 | } else if (userClaims is JSONArray) {
119 | val reference = if (discloseStructure !is JSONArray || discloseStructure.length() == 0) {
120 | JSONObject()
121 | } else {
122 | discloseStructure.get(0)
123 | }
124 | val sdClaims = JSONArray()
125 | for (i in 0 until userClaims.length()) {
126 | sdClaims.put(
127 | createSdClaims(
128 | userClaims.get(i),
129 | reference,
130 | disclosures,
131 | decoy
132 | )
133 | )
134 | }
135 | return sdClaims
136 | } else {
137 | return userClaims
138 | }
139 | }
140 |
141 | /**
142 | * This method creates a SD-JWT credential that contains the claims
143 | * passed to the method and is signed by the provided signer.
144 | *
145 | * @param userClaims A kotlinx serializable data class that contains the user's claims (all types must be nullable and default value must be null)
146 | * @param signer A concrete signer instance for signing the SD-JWT as the issuer
147 | * @param holderPubKey Optional: The holder's public key if holder binding is required
148 | * @param discloseStructure Optional: Class that has a non-null value for each object that should be disclosable separately
149 | * @param decoy Optional: If true, add decoy values to the SD digest arrays (default: true)
150 | * @return Serialized SD-JWT + disclosures to send to the holder
151 | */
152 | inline fun createCredential(
153 | userClaims: T,
154 | signer: SdJwtSigner,
155 | holderPubKey: JWK? = null,
156 | discloseStructure: T? = null,
157 | decoy: Boolean = true
158 | ): String {
159 | val jsonUserClaims = JSONObject(Json.encodeToString(userClaims))
160 | val jsonDiscloseStructure = if (discloseStructure != null) {
161 | JSONObject(Json.encodeToString(discloseStructure))
162 | } else {
163 | JSONObject()
164 | }
165 |
166 | return createCredential(
167 | userClaims = jsonUserClaims,
168 | signer = signer,
169 | holderPubKey = holderPubKey,
170 | discloseStructure = jsonDiscloseStructure,
171 | decoy = decoy
172 | )
173 | }
174 |
175 | /**
176 | * This method creates a SD-JWT credential that contains the claims
177 | * passed to the method and is signed by the provided signer.
178 | *
179 | * @param userClaims A JSONObject that contains the user's claims (all types must be nullable and default value must be null)
180 | * @param signer A concrete signer instance for signing the SD-JWT as the issuer
181 | * @param holderPubKey Optional: The holder's public key if holder binding is required
182 | * @param discloseStructure Optional: JSONObject that has a non-null value for each element that should be disclosable separately
183 | * @param decoy Optional: If true, add decoy values to the SD digest arrays (default: true)
184 | * @return Serialized SD-JWT + disclosures to send to the holder
185 | */
186 | fun createCredential(
187 | userClaims: JSONObject,
188 | signer: SdJwtSigner,
189 | holderPubKey: JWK? = null,
190 | discloseStructure: JSONObject = JSONObject(),
191 | decoy: Boolean = true
192 | ): String {
193 | val disclosures = mutableListOf()
194 | val sdClaimsSet = createSdClaims(userClaims, discloseStructure, disclosures, decoy) as JSONObject
195 |
196 | val sdJwtPayload = JWTClaimsSet.Builder()
197 |
198 | for (key in sdClaimsSet.keys()) {
199 | if (sdClaimsSet.get(key) is JSONObject) {
200 | sdJwtPayload.claim(key, sdClaimsSet.getJSONObject(key).toMap())
201 | } else if (sdClaimsSet.get(key) is JSONArray) {
202 | sdJwtPayload.claim(key, sdClaimsSet.getJSONArray(key).toList())
203 | } else {
204 | sdJwtPayload.claim(key, sdClaimsSet.get(key))
205 | }
206 | }
207 |
208 | sdJwtPayload.claim(DIGEST_ALG_KEY, "sha-256")
209 |
210 | if (holderPubKey != null) {
211 | sdJwtPayload.claim(
212 | HOLDER_BINDING_KEY,
213 | JSONObject().put("jwk", holderPubKey.toJSONObject()).toMap()
214 | )
215 | }
216 |
217 |
218 | val sdJwtEncoded = buildJWT(sdJwtPayload.build(), signer)
219 |
220 | return sdJwtEncoded + SEPARATOR + disclosures.joinToString(SEPARATOR)
221 | }
222 |
223 | /**
224 | * @suppress
225 | * This method is not for API users.
226 | */
227 | fun parseDisclosures(credentialParts: List, offset: Int = 0): Pair, String?> {
228 | val disclosures = HashMap()
229 | var holderJwt: String? = null
230 | for (disclosure in credentialParts.subList(1, credentialParts.size - offset)) {
231 | disclosures[createHash(disclosure)] = disclosure
232 | }
233 | if (credentialParts.last() != "") {
234 | holderJwt = credentialParts.last()
235 | }
236 | return Pair(disclosures, holderJwt)
237 | }
238 |
239 | /**
240 | * @suppress
241 | * This method is not for API users.
242 | */
243 | fun findDisclosures(
244 | credentialClaims: Any,
245 | revealClaims: Any,
246 | disclosures: HashMap,
247 | findAll: Boolean = false
248 | ): List {
249 | val revealDisclosures = mutableListOf()
250 | if (credentialClaims is JSONObject && (revealClaims is JSONObject || findAll)) {
251 | for (key in credentialClaims.keys()) {
252 | if (key == SD_DIGEST_KEY) {
253 | for (digest in credentialClaims.getJSONArray(key)) {
254 | if (disclosures.containsKey(digest)) {
255 | val b64Disclosure = disclosures[digest]
256 | val disclosure = JSONArray(b64Decode(b64Disclosure))
257 | // If the disclosure contains a SD_DIGEST_KEY key, we have to recursively process the structure.
258 | if (disclosure.get(2) is JSONObject && disclosure.getJSONObject(2).has(SD_DIGEST_KEY)
259 | // Check whether the disclosure should be revealed based on the existence of the key
260 | // in the revealClaims structure.
261 | && ((revealClaims as JSONObject).has(HIDE_NAME + disclosure.getString(1)) || findAll)
262 | ) {
263 | revealDisclosures.add(b64Disclosure!!)
264 | revealDisclosures.addAll(
265 | findDisclosures(
266 | disclosure.getJSONObject(2),
267 | if (!findAll) revealClaims.get(HIDE_NAME + disclosure.getString(1)) else revealClaims,
268 | disclosures,
269 | findAll
270 | )
271 | )
272 | } else if ((revealClaims as JSONObject).has(disclosure.getString(1)) || findAll) {
273 | revealDisclosures.add(b64Disclosure!!)
274 | }
275 | }
276 | }
277 | } else if ((revealClaims as JSONObject).has(key) || findAll) {
278 | revealDisclosures.addAll(
279 | findDisclosures(
280 | credentialClaims.get(key),
281 | if (!findAll) revealClaims.get(key) else revealClaims,
282 | disclosures,
283 | findAll
284 | )
285 | )
286 | }
287 | }
288 | } else if (credentialClaims is JSONArray) {
289 | val reference = if (revealClaims !is JSONArray || revealClaims.length() == 0) {
290 | JSONObject()
291 | } else {
292 | revealClaims.get(0)
293 | }
294 | for (item in credentialClaims) {
295 | revealDisclosures.addAll(findDisclosures(item, reference, disclosures, findAll))
296 | }
297 | }
298 | return revealDisclosures
299 | }
300 |
301 | /**
302 | * @suppress
303 | * This method is not for API users.
304 | *
305 | * This method checks if every disclosure has a matching digest in the SD-JWT.
306 | */
307 | fun checkDisclosuresMatchingDigest(sdJwt: JSONObject, disclosureMap: HashMap) {
308 | val allDisclosures = findDisclosures(sdJwt, JSONObject(), disclosureMap, true)
309 | val credentialDisclosureList = disclosureMap.values
310 | if (!allDisclosures.containsAll(credentialDisclosureList) || !credentialDisclosureList.containsAll(allDisclosures)) {
311 | throw Exception("Digest and disclosure values do not match")
312 | }
313 | }
314 |
315 | /**
316 | * This method takes an SD-JWT and its disclosures and
317 | * creates a presentation that discloses only the desired claims.
318 | *
319 | * @param credential A string containing the SD-JWT and its disclosures concatenated by a period character
320 | * @param releaseClaims An object of the same class as the credential and every claim that should be disclosed contains a non-null value
321 | * @param audience Optional: The value of the "aud" claim in the holder JWT
322 | * @param nonce Optional: The value of the "nonce" claim in the holder JWT
323 | * @param holderSigner Optional: A signer instance for the holder, only needed if holder binding is required
324 | * @return Serialized SD-JWT + disclosures [+ holder JWT] concatenated by a ~ character
325 | */
326 | inline fun createPresentation(
327 | credential: String,
328 | releaseClaims: T,
329 | audience: String? = null,
330 | nonce: String? = null,
331 | holderSigner: SdJwtSigner? = null,
332 | ): String {
333 | val releaseClaimsJson = JSONObject(Json.encodeToString(releaseClaims))
334 |
335 | return internalCreatePresentation(
336 | credential = credential,
337 | releaseClaims = releaseClaimsJson,
338 | audience = audience,
339 | nonce = nonce,
340 | holderSigner = holderSigner
341 | )
342 | }
343 |
344 | /**
345 | * @suppress
346 | * This method is not for API users.
347 | */
348 | fun internalCreatePresentation(
349 | credential: String,
350 | releaseClaims: JSONObject,
351 | audience: String? = null,
352 | nonce: String? = null,
353 | holderSigner: SdJwtSigner? = null
354 | ): String {
355 | val credentialParts = credential.split(SEPARATOR)
356 | var presentation = credentialParts[0]
357 |
358 | // Parse credential into formats suitable to process it
359 | val sdJwt = parseJWT(credentialParts[0])
360 | val (disclosureMap, _) = parseDisclosures(credentialParts)
361 |
362 | checkDisclosuresMatchingDigest(sdJwt, disclosureMap)
363 |
364 | val releaseDisclosures = findDisclosures(sdJwt, releaseClaims, disclosureMap)
365 |
366 | if (releaseDisclosures.isNotEmpty()) {
367 | presentation += SEPARATOR + releaseDisclosures.joinToString(SEPARATOR)
368 | }
369 |
370 | // Throw an exception if the holderKey is not null but there is no
371 | // key referenced in the credential.
372 | if (sdJwt.isNull(HOLDER_BINDING_KEY) && holderSigner != null) {
373 | throw Exception("SD-JWT has no holder binding and the holderKey is not null. Presentation would be signed with a key not referenced in the credential.")
374 | }
375 |
376 | // Check whether the bound key is the same as the key that
377 | // was passed to this method
378 | if (!sdJwt.isNull(HOLDER_BINDING_KEY) && holderSigner != null) {
379 | val boundKey = JWK.parse(sdJwt.getJSONObject(HOLDER_BINDING_KEY).getJSONObject("jwk").toString())
380 | val holderPubKey = holderSigner.getPublicJWK()
381 |
382 | if (jwkThumbprint(boundKey) != jwkThumbprint(holderPubKey)) {
383 | throw Exception("Passed holder key is not the same as in the credential")
384 | }
385 | }
386 |
387 | if (nonce != null || audience != null) {
388 | val holderBindingJwtPayload = JWTClaimsSet.Builder()
389 | .audience(audience)
390 | .issueTime(Date.from(Instant.now()))
391 | .claim("nonce", nonce)
392 | .build()
393 |
394 | presentation += SEPARATOR + buildJWT(holderBindingJwtPayload, holderSigner)
395 | } else {
396 | presentation += SEPARATOR
397 | }
398 |
399 | return presentation
400 | }
401 |
402 | /**
403 | * This method takes an SD-JWT and its disclosures and
404 | * creates a presentation that discloses only the desired claims.
405 | *
406 | * @param credential A string containing the SD-JWT and its disclosures concatenated by a period character
407 | * @param releaseClaims A JSONObject contains a non-null value for every claim that should be presented
408 | * @param audience Optional: The value of the "aud" claim in the holder JWT
409 | * @param nonce Optional: The value of the "nonce" claim in the holder JWT
410 | * @param holderSigner Optional: A signer instance for the holder, only needed if holder binding is required
411 | * @return Serialized SD-JWT + disclosures [+ holder JWT] concatenated by a ~ character
412 | */
413 | fun createPresentation(
414 | credential: String,
415 | releaseClaims: JSONObject,
416 | audience: String? = null,
417 | nonce: String? = null,
418 | holderSigner: SdJwtSigner? = null
419 | ): String {
420 | return internalCreatePresentation(
421 | credential = credential,
422 | releaseClaims = releaseClaims,
423 | audience = audience,
424 | nonce = nonce,
425 | holderSigner = holderSigner
426 | )
427 | }
428 |
429 | /**
430 | * @suppress
431 | * This method is not for API users.
432 | */
433 | fun buildJWT(claims: JWTClaimsSet, signer: SdJwtSigner?): String {
434 | if (signer == null) {
435 | val header = PlainHeader.Builder()
436 | return PlainJWT(header.build(), claims).serialize()
437 | }
438 |
439 | val signedJwt = SignedJWT(signer.sdJwtHeader(), claims)
440 | signedJwt.sign(signer.jwsSigner())
441 | return signedJwt.serialize()
442 | }
443 |
444 | /**
445 | * @suppress
446 | * This method is not for API users. Use 'verifyPresentation' method.
447 | */
448 | fun verifyAndBuildCredential(credentialClaims: Any, disclosures: HashMap): Any {
449 | if (credentialClaims is JSONObject) {
450 | val claims = JSONObject()
451 | for (key in credentialClaims.keys()) {
452 | if (key == SD_DIGEST_KEY) {
453 | for (digest in credentialClaims.getJSONArray(key)) {
454 | if (disclosures.containsKey(digest)) {
455 | val b64Disclosure = disclosures[digest]
456 | val disclosure = JSONArray(b64Decode(b64Disclosure))
457 | if (disclosure.get(2) is JSONObject && disclosure.getJSONObject(2).has(SD_DIGEST_KEY)) {
458 | val keyWithPrefix = HIDE_NAME + disclosure[1]
459 | claims.put(
460 | keyWithPrefix,
461 | verifyAndBuildCredential(disclosure.getJSONObject(2), disclosures)
462 | )
463 | } else {
464 | claims.put(disclosure[1] as String, disclosure[2])
465 | }
466 | }
467 | }
468 | } else {
469 | claims.put(key, verifyAndBuildCredential(credentialClaims.get(key), disclosures))
470 | }
471 | }
472 | return claims
473 | } else if (credentialClaims is JSONArray) {
474 | val claims = JSONArray()
475 | for (item in credentialClaims) {
476 | claims.put(verifyAndBuildCredential(item, disclosures))
477 | }
478 | return claims
479 | }
480 | // Assume we have a real claim and not a digest value
481 | return credentialClaims
482 | }
483 |
484 |
485 | /**
486 | * The method takes a serialized SD-JWT + disclosures [+ holder JWT], parses it and checks
487 | * the validity of the credential. The disclosed claims are returned in an object
488 | * of the credential class.
489 | *
490 | * @param presentation Serialized presentation containing the SD-JWT and the disclosures
491 | * @param sdJwtVerifier The verifier used to verify the SD-JWT
492 | * @param expectedNonce Optional: The value that is expected in the nonce claim of the holder JWT
493 | * @param expectedAud Optional: The value that is expected in the aud claim of the holder JWT
494 | * @param verifyHolderBinding Optional: Determine whether holder binding is required by the verifier's policy (default: true)
495 | * @return An object of the credential class filled with the disclosed claims
496 | */
497 | inline fun verifyPresentation(
498 | presentation: String,
499 | sdJwtVerifier: SdJwtVerifier,
500 | expectedNonce: String? = null,
501 | expectedAud: String? = null,
502 | verifyHolderBinding: Boolean = true,
503 | ): T {
504 |
505 | val sdClaimsParsedJson = verifyPresentation(
506 | presentation, sdJwtVerifier, expectedNonce, expectedAud, verifyHolderBinding
507 | )
508 |
509 | val format = Json { ignoreUnknownKeys = true }
510 | return format.decodeFromString(sdClaimsParsedJson.toString())
511 | }
512 |
513 | /**
514 | * The method takes a serialized SD-JWT + disclosures [+ holder JWT], parses it and checks
515 | * the validity of the credential. The disclosed claims are returned in an object
516 | * of the credential class.
517 | *
518 | * @param presentation Serialized presentation containing the SD-JWT and the disclosures
519 | * @param sdJwtVerifier The verifier used to verify the SD-JWT
520 | * @param expectedNonce Optional: The value that is expected in the nonce claim of the holder JWT
521 | * @param expectedAud Optional: The value that is expected in the aud claim of the holder JWT
522 | * @param verifyHolderBinding Optional: Determine whether holder binding is required by the verifier's policy (default: true)
523 | * @return A JSONObject of the credential class filled with the disclosed claims
524 | */
525 | fun verifyPresentation(
526 | presentation: String,
527 | sdJwtVerifier: SdJwtVerifier,
528 | expectedNonce: String? = null,
529 | expectedAud: String? = null,
530 | verifyHolderBinding: Boolean = true,
531 | ): JSONObject {
532 | val presentationSplit = presentation.split(SEPARATOR)
533 | val (disclosureMap, holderJwt) = parseDisclosures(presentationSplit, 1)
534 |
535 | // Verify SD-JWT
536 | val sdJwtParsed = verifySDJWT(presentationSplit[0], sdJwtVerifier)
537 | verifyJwtClaims(sdJwtParsed)
538 |
539 | // Verify holder binding if required by the verifier's policy.
540 | // If holder binding is not required check nonce and aud if passed to this method.
541 | if (verifyHolderBinding && holderJwt == null) {
542 | throw Exception("No holder binding in presentation but required by the verifier's policy.")
543 | }
544 | if (verifyHolderBinding) {
545 | val parsedHolderJwt = verifyHolderBindingJwt(holderJwt!!, sdJwtParsed)
546 | verifyJwtClaims(parsedHolderJwt, expectedNonce, expectedAud)
547 | } else if ((expectedNonce != null || expectedAud != null) && holderJwt != null) {
548 | val parsedHolderJwt = parsePlainJwt(holderJwt)
549 | verifyJwtClaims(parsedHolderJwt, expectedNonce, expectedAud)
550 | } else if (expectedNonce != null || expectedAud != null) {
551 | throw Exception("Verifier wants to verify nonce or aud claim but there was no holder JWT in the credential.")
552 | }
553 |
554 | // Check that every disclosure has a matching digest
555 | checkDisclosuresMatchingDigest(sdJwtParsed, disclosureMap)
556 |
557 | val sdClaimsParsed = verifyAndBuildCredential(sdJwtParsed, disclosureMap)
558 |
559 | // Exclude technical claims
560 | val sdClaimsParsedFiltered = JSONObject(sdClaimsParsed.toString()).toMap().filterKeys { key ->
561 | key !in setOf(
562 | SD_DIGEST_KEY,
563 | DIGEST_ALG_KEY,
564 | HOLDER_BINDING_KEY
565 | )
566 | }
567 |
568 | return JSONObject(sdClaimsParsedFiltered)
569 | }
570 |
571 | /**
572 | * @suppress
573 | * This method is not for API users. Use 'verifyPresentation' method.
574 | */
575 | fun verifySDJWT(jwt: String, sdJwtVerifier: SdJwtVerifier): JSONObject {
576 | val signedJwt = SignedJWT.parse(jwt)
577 | val jwtVerifier = sdJwtVerifier.jwsVerifier(signedJwt)
578 |
579 | if (signedJwt.verify(jwtVerifier)) {
580 | return JSONObject(signedJwt.payload.toJSONObject())
581 | } else {
582 | throw Exception("Could not verify SD-JWT")
583 | }
584 | }
585 |
586 | /**
587 | * @suppress
588 | * This method is not for API users. Use 'verifyPresentation' method.
589 | */
590 | fun verifyHolderBindingJwt(jwt: String, sdJwtParsed: JSONObject): JSONObject {
591 | val holderPubKey = if (!sdJwtParsed.isNull(HOLDER_BINDING_KEY)) {
592 | sdJwtParsed.getJSONObject(HOLDER_BINDING_KEY).getJSONObject("jwk").toString()
593 | } else {
594 | throw Exception("Holder binding is missing in SD-JWT. Expected $HOLDER_BINDING_KEY claim with a JWK.")
595 | }
596 |
597 | if (verifyJWTSignature(jwt, holderPubKey)) {
598 | return parseJWT(jwt)
599 | } else {
600 | throw Exception("Could not verify holder binding JWT")
601 | }
602 | }
603 |
604 | /**
605 | * @suppress
606 | * This method is not for API users. Use 'verifyPresentation' method.
607 | */
608 | private fun verifyJWTSignature(jwt: String, jwkStr: String): Boolean {
609 | return SignedJWT.parse(jwt).verify(JWK.parse(jwkStr).jwsVerifier())
610 | }
611 |
612 | /**
613 | * @suppress
614 | * This method is not for API users. Use 'verifyPresentation' method.
615 | */
616 | fun verifyJwtClaims(claims: JSONObject, expectedNonce: String? = null, expectedAud: String? = null) {
617 | if (expectedNonce != null && claims.getString("nonce") != expectedNonce) {
618 | throw Exception("JWT claims verification failed (invalid nonce)")
619 | }
620 | if (expectedAud != null && claims.getString("aud") != expectedAud) {
621 | throw Exception("JWT claims verification failed (invalid audience)")
622 | }
623 |
624 | val date = Date(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) * 1000)
625 | // Check that the JWT is already valid with an offset of 30 seconds
626 | if (!claims.isNull("iat") && !date.after(Date((claims.getLong("iat") - 30) * 1000))) {
627 | throw Exception("JWT not yet valid")
628 | }
629 | if (!claims.isNull("exp") && !date.before(Date(claims.getLong("exp") * 1000))) {
630 | throw Exception("JWT is expired")
631 | }
632 | }
633 |
634 |
635 | /**
636 | * @suppress
637 | * This method is not for API users.
638 | */
639 | fun parseJWT(jwt: String): JSONObject {
640 | return JSONObject(SignedJWT.parse(jwt).payload.toJSONObject())
641 | }
642 |
643 | /**
644 | * @suppress
645 | * This method is not for API users.
646 | */
647 | fun parsePlainJwt(jwt: String): JSONObject {
648 | return JSONObject(PlainJWT.parse(jwt).payload.toJSONObject())
649 | }
650 |
651 | /**
652 | * @suppress
653 | * This method is not for API users.
654 | */
655 | fun b64Encoder(str: String): String {
656 | return Base64.getUrlEncoder().withoutPadding().encodeToString(str.toByteArray())
657 | }
658 |
659 | /**
660 | * @suppress
661 | * This method is not for API users.
662 | */
663 | private fun b64Encoder(b: ByteArray): String {
664 | return Base64.getUrlEncoder().withoutPadding().encodeToString(b)
665 | }
666 |
667 | /**
668 | * @suppress
669 | * This method is not for API users.
670 | */
671 | fun b64Decode(str: String?): String {
672 | return String(Base64.getUrlDecoder().decode(str))
673 | }
674 |
675 | /**
676 | * @suppress
677 | * This method is not for API users.
678 | */
679 | fun jwkThumbprint(jwk: JWK): String {
680 | return b64Encoder(jwk.computeThumbprint().decode())
681 | }
682 |
683 | internal fun JWK.jwsVerifier(): JWSVerifier = when (keyType) {
684 | KeyType.OKP -> {
685 | Ed25519Verifier(toOctetKeyPair())
686 | }
687 | KeyType.RSA -> {
688 | RSASSAVerifier(toRSAKey())
689 | }
690 | KeyType.EC -> {
691 | ECDSAVerifier(toECKey())
692 | }
693 | else -> {
694 | throw NotImplementedError("JWK signing algorithm not implemented")
695 | }
696 | }
--------------------------------------------------------------------------------
/src/main/kotlin/org/sd_jwt/SdJwtSigner.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.JWSHeader
4 | import com.nimbusds.jose.JWSSigner
5 | import com.nimbusds.jose.jwk.JWK
6 |
7 | /**
8 | * A generic interface for SD-JWT signers.
9 | */
10 | interface SdJwtSigner {
11 |
12 | /**
13 | * Gets the SD-JWT header that is configured with all parameters required for signing and SD-JWT specific parameters.
14 | *
15 | * @return the SD-JWT header.
16 | */
17 | fun sdJwtHeader(): JWSHeader
18 |
19 | /**
20 | * Gets the JWS signer used for signing SD-JWTs.
21 | *
22 | * @return the JWS signer.
23 | */
24 | fun jwsSigner(): JWSSigner
25 |
26 |
27 | /**
28 | * Gets the public JWK of the key pair used to sign SD-JWTs.
29 | *
30 | * @return the public JWK.
31 | */
32 | fun getPublicJWK(): JWK
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/kotlin/org/sd_jwt/SdJwtVerifier.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.JWSVerifier
4 | import com.nimbusds.jwt.SignedJWT
5 |
6 | /**
7 | * Implements verification rules for an SD-JWT.
8 | */
9 | interface SdJwtVerifier {
10 | /**
11 | * This method returns a JWSVerifier based on a given SignedJWT (containing the SD-JWT payload).
12 | *
13 | * This approach allows to respect values set in the header and claims to construct the JWSVerifier and thus allows
14 | * to implement the necessary verification steps demanded by specifications like SD-JWT VC.
15 | *
16 | * @return a JWSVerifier that will be used to verify the SD-JWT signature.
17 | */
18 | fun jwsVerifier(jwt: SignedJWT): JWSVerifier
19 | }
--------------------------------------------------------------------------------
/src/main/kotlin/org/sd_jwt/TrustedIssuersSdJwtVerifier.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.JWSVerifier
4 | import com.nimbusds.jose.jwk.JWK
5 | import com.nimbusds.jwt.SignedJWT
6 |
7 | /**
8 | * An SD-JWT verifier that selects the issuer key used for verification based on the iss-claim in the SD-JWT.
9 | *
10 | * @param issuerKeys The issuer keys. The keys are matched against the iss claim in the SD-JWT and the values must be encoded JWKs.
11 | */
12 | class TrustedIssuersSdJwtVerifier(private val issuerKeys: Map) : SdJwtVerifier {
13 | override fun jwsVerifier(jwt: SignedJWT): JWSVerifier {
14 | val payload = jwt.payload.toJSONObject()
15 | val issuer = payload["iss"] ?: throw Exception("Could not find issuer in JWT")
16 | val key = issuerKeys[issuer] ?: throw Exception("Could not find signing key to verify JWT")
17 | return JWK.parse(key).jwsVerifier()
18 | }
19 | }
--------------------------------------------------------------------------------
/src/test/kotlin/org/sd_jwt/AdvancedTest.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.jwk.RSAKey
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import org.junit.jupiter.api.Test
7 |
8 | internal class AdvancedTest {
9 |
10 | private val verifier = "https://example.com/verifier"
11 | private val issuer = "https://example.com/issuer"
12 |
13 | private val issuerKeyJson = """
14 | {
15 | "d": "JQ5-MZ5wuwb8KBYiJqDbtCG3H9daEK-ITOnxWP7k7jcI4lotkO3vmMuCw_XJQKShUV6TpeI7AT_je1SY_7-ram2oM1xJcm0zoOUOvK62l7006bUB3BfHmYXEdEtr_-bzA_mMwpQsEztT_V0BNIFwX-oXnO9LXSTrgFcUTUnS_Vyp-0noziWQN4sx5YlBTniRIhAyU1eYqUDpqza2hmKJEpEYUR73h3OLUEQJblEY4-WR989MK4ff_GcJ7y1dV8YraTmsoOKs2qmelMdfO_SgZ5SjKNtl38yvr8hkEJpXgbBJV1bjzu2IOysxmxtrOjxHRjDHQEV2MAoYObJki33rzQ",
16 | "dp": "gDE4XKCd_TbQLH_buP3UDpgCSi3TmdaTfmiNyJHxrNqBTehsMYhEUDN2t84NEJKF-QXWaRP1IHb3T5MvDNrXZUf8vHQFh6BXcOceF2dC_PvGIX3K1Nwnb8T9u1VkwaN95h_hMoCk7E8mKw37cX4eeoRqtLsxBSFODbIhi4b9Yq0",
17 | "dq": "c26RA1V_1rX8sfrMMkCDADbb7tD55h8obuX2FMs2LhBs4T9vzwsm8dKZ1cl0VYui04hc-x6tAMwYFrz4Y0cGBcHQHgOL1ame_pQos1tCbOChBeczXVLlcKhwsvFCNjkM4jV05o8PHZ9Jk8dFbGJ_1RLTgaGLktFQgfkas8VjwKs",
18 | "e": "AQAB",
19 | "key_size": 2048,
20 | "kty": "RSA",
21 | "n": "6GwTTwcjVyOtKtuGf7ft5PAU0GiDtnD4DGcmtVrFQHVhtx05-DJigfmR-3Tetw-Od5su4TNZYzjh3tQ6Bj1HRdOfGmX9E9YbPw4goKg_d0kM4oZMUd64tmlAUFtX0NYaYnRkjQtok2CJBUq22wucK93JV11T38PYDATqbK9UFqMM3vu07XXlaQGXP1vh4iX04w4dU4d2xTACXho_wKKcV85yvIGrO1eGwwnSilTiqQbak31_VnHGNVVZEk4dnVO7eOc6MVZa-qPkVj77GaILO53TMq69Vp1faJoGFHjha_Ue5D8zfpiAEx2AsAeotIwNk2QT0UZkeZoK23Q-s4p1dQ",
22 | "p": "8_vXPiy3OtAeACYgNm6iIo5c1Cbwuh3tJ0T6lMcGEo7Rcro0nwNFISvPnFp_1Wl8I1ts6FTMsKyoTneveDrptlWSRRZNrFS_GyAQpG6GPUfqNh9n4T5J3mYw6-fLPM0hL0_EbDNiEXyL53ecMfi2xlg2T2opuZFeToogqipDudc",
23 | "q": "8953MqqJ7v-bc5rPQuRjqbHIxZJdEF-VsSz1lVbVEqnxV0XEUnM8yZqsXUe07V-5OEzJBqgrgLCcOeh5Jfs1MZI9tegRCwdw3uiqECAAVMtsM9xCwBY0mPu-oqOwaKsVOj2Slr1Gq-s67FdjGeMq6udjPWHgQ5QeOy78pgHtWZM",
24 | "qi": "FghQIPGfbjWmdwl5szDRPq1_NcGWSt9Eswu5o-JJq-jWUgTljqxufteg96k7pmBXMAQjGKn_lY41AojokVB4KWTJrPHF6z6oAm90kMLuFi80IbXzdb6TnsYHue_Y3Tbs4GtYP7YU9x2zrghsaUcDNJ7yH13h9F7GyiDkpySgcaM"
25 | }
26 | """.trimIndent()
27 | private val issuerKey = RSAKey.parse(issuerKeyJson)
28 | private val issuerSigner = KeyBasedSdJwtSigner(issuerKey)
29 | private val holderKeyJson = """
30 | {
31 | "d": "kJSUdxpBVUHSSe0HfJfeO3q-iDgjXlS9zEZmgifbUPtjcT8recXwmwwRTZzhb9avNy8tyL8i1dJooAeMnudECz4u5zRY6VIXnSkO2cSPhZ-fyXPpC1BAnzf8RSn8rGu_auRrfyq3dfYw6dLt7dzA-hsUANzD63x8Tt4v9eiwsp65BlR1pvf0BIV3WMGLtgx0hTUQBUxIx0hgDG439a0gLY0T86m9LEMCcVXONNTWbScQf5KsHLWQgbjCeUc_4szy4RwsaFnF40uut_fdZyM_O1pOsfYJLa8fmN3FC72l4UdJvtFXWuH-20ywTEOKISF7CRx5BsifOnyEMTeAVEE9wQ",
32 | "dp": "kqCTyxU7gJa3gY4tn9OABui7por98yRlQUl7HYo63nPYPhCK3zMFcEOL8xjYot1cYYCGxE5yFxqkbX9fmbWEsRmx_BsgRPdraZ5DhvCES3BYstJAVctS-2LikGMK7veV7r6tEoKPvmKrkOKH90_-0GVvdG0GJn7Ccqz9OTWa1sE",
33 | "dq": "DYqOZnhR_1GZhNaMyVdAOcLt3Sw20TL90pEPSbYLGtcBLqZkyo9wNtMguYd_YFXHojF_iNwQW9IdYE7hVgA87tLEgM8S-1zQFVI2jGkBbqHisncQ4NdbEdIXxc3YHyCQmurPPW_EjKhyRKzHoalkJoUUSWF0S34MXoiFHIEae-s",
34 | "e": "AQAB",
35 | "key_size": 2048,
36 | "kty": "RSA",
37 | "n": "pm4bOHBg-oYhAyPWzR56AWX3rUIXp11_ICDkGgS6W3ZWLts-hzwI3x65659kg4hVo9dbGoCJE3ZGF_eaetE30UhBUEgpGwrDrQiJ9zqprmcFfr3qvvkGjtth8Zgl1eM2bJcOwE7PCBHWTKWYs152R7g6Jg2OVph-a8rq-q79MhKG5QoW_mTz10QT_6H4c7PjWG1fjh8hpWNnbP_pv6d1zSwZfc5fl6yVRL0DV0V3lGHKe2Wqf_eNGjBrBLVklDTk8-stX_MWLcR-EGmXAOv0UBWitS_dXJKJu-vXJyw14nHSGuxTIK2hx1pttMft9CsvqimXKeDTU14qQL1eE7ihcw",
38 | "p": "0AZrdzBIpxDQggVh0x4GYBmNDuC8Ut_qOAKNLbpJLaWHFmeMjQRnXM8nxZfmhzAQ10XAS6n7TyFqK-PrhfmKWZ0g34UVfeXd4-D-gqegIDZ3TNwNCOBLOpwdDrHeB06ZdJ1o2OI1XLTO12PQN6PRUVKKF0dFdXV7NAM8YpJkxmE",
39 | "q": "zM_2m4uE2ldfNMOJmCMRm2S2NpiMOYi3Pp6Q6c4QtpF1up0Bak0Whox4F6VN6ydJjgolXFITufUU4XhT8p9WvDdCrY5u3NWbGMXMC426JPHXBKdHqQvAf3LFcbWNjrjowBktkPyDbB5sL3H8ey-q6tzGqLirZGZSKFiZ6J3OUFM",
40 | "qi": "O7leKcjIonKzTlI2EcShf4Vdlw-AvlQqAHmpGttHP0Vr--R4RteORtdXGUZC92GNaiHmkDLwak8ENfewKUP9xMyE_Psc5N090P_y9yKaIQnqN5QYe7quisqYtD64xP-568JaQCCqUtrVFT62jFhl0cVQ8Fy2oqdaKBufjLv-ssc"
41 | }
42 | """.trimIndent()
43 | private val holderKey = RSAKey.parse(holderKeyJson)
44 | private val holderSigner = KeyBasedSdJwtSigner(holderKey)
45 |
46 | private val trustedIssuers = mutableMapOf(issuer to issuerKey.toPublicJWK().toJSONString())
47 |
48 | private val nonce = "yoxCiDm5sVP-OTNYta_DDg"
49 |
50 | @Serializable
51 | private data class Address(
52 | @SerialName("street_address") val streetAddress: String? = null,
53 | val locality: String? = null,
54 | val region: String? = null,
55 | val country: String? = null
56 | )
57 |
58 | @Serializable
59 | private data class SimpleCredential(
60 | val iss: String,
61 | val sub: String? = null,
62 | @SerialName("given_name") val givenName: String? = null,
63 | @SerialName("family_name") val familyName: String? = null,
64 | val email: String? = null,
65 | @SerialName("phone_number") val phoneNumber: String? = null,
66 | val address: Address? = null,
67 | val birthdate: String? = null
68 | )
69 |
70 | @Test
71 | internal fun simpleTest() {
72 | val testConfig = TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, holderSigner, "Simple Test")
73 | val claims = SimpleCredential(
74 | issuer,
75 | "6c5c0a49-b589-431d-bae7-219122a9ec2c",
76 | "John",
77 | "Doe",
78 | "johndoe@example.com",
79 | "+1-202-555-0101",
80 | Address("123 Main St", "Anytown", "Anystate", "US"),
81 | "1940-01-01"
82 | )
83 | val discloseStructure = SimpleCredential(iss = "")
84 | val releaseClaims = SimpleCredential(iss = "", givenName = "", familyName = "", address = Address())
85 | val expectedClaims = SimpleCredential(
86 | iss = issuer,
87 | givenName = "John",
88 | familyName = "Doe",
89 | address = Address(streetAddress = "123 Main St", locality = "Anytown", region = "Anystate", country = "US")
90 | )
91 |
92 | val expectedClaimsKeys = listOf("given_name", "family_name", "address")
93 |
94 | testRoutine(expectedClaimsKeys, expectedClaims, claims, discloseStructure, releaseClaims, testConfig)
95 | }
96 |
97 | @Test
98 | internal fun simpleStructuredTest() {
99 | val testConfig =
100 | TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, holderSigner, "Simple Structured Test")
101 | val claims = SimpleCredential(
102 | issuer,
103 | "6c5c0a49-b589-431d-bae7-219122a9ec2c",
104 | "John",
105 | "Doe",
106 | "johndoe@example.com",
107 | "+1-202-555-0101",
108 | Address("123 Main St", "Anytown", "Anystate", "US"),
109 | "1940-01-01"
110 | )
111 | val discloseStructure = SimpleCredential(iss = "", address = Address())
112 | val releaseClaims = SimpleCredential(
113 | iss = "",
114 | givenName = "",
115 | familyName = "",
116 | address = Address(region = "", country = ""),
117 | birthdate = ""
118 | )
119 | val expectedClaims = SimpleCredential(
120 | iss = issuer,
121 | givenName = "John",
122 | familyName = "Doe",
123 | birthdate = "1940-01-01",
124 | address = Address(region = "Anystate", country = "US")
125 | )
126 |
127 | val expectedClaimsKeys = listOf("given_name", "family_name", "birthdate", "region", "country")
128 |
129 | testRoutine(
130 | expectedClaimsKeys,
131 | expectedClaims,
132 | claims,
133 | discloseStructure,
134 | releaseClaims,
135 | testConfig
136 | )
137 | }
138 |
139 | @Serializable
140 | private data class Issuer(
141 | val name: String? = null,
142 | val country: String? = null
143 | )
144 |
145 | @Serializable
146 | private data class Document(
147 | val type: String? = null,
148 | val issuer: Issuer? = null,
149 | val number: String? = null,
150 | @SerialName("date_of_issuance") val dateOfIssuance: String? = null,
151 | @SerialName("date_of_expiry") val dataOfExpiry: String? = null
152 | )
153 |
154 | @Serializable
155 | private data class Evidence(
156 | val type: String? = null,
157 | val method: String? = null,
158 | val time: String? = null,
159 | val document: Document? = null
160 | )
161 |
162 | @Serializable
163 | private data class PlaceOfBirth(
164 | val country: String? = null,
165 | val locality: String? = null
166 | )
167 |
168 | @Serializable
169 | private data class AddressComplex(
170 | val locality: String? = null,
171 | @SerialName("postal_code") val postalCode: String? = null,
172 | val country: String? = null,
173 | @SerialName("street_address") val streetAddress: String? = null
174 | )
175 |
176 | @Serializable
177 | private data class Claims(
178 | @SerialName("given_name") val givenName: String? = null,
179 | @SerialName("family_name") val familyName: String? = null,
180 | val birthdate: String? = null,
181 | @SerialName("place_of_birth") val placeOfBirth: PlaceOfBirth? = null,
182 | val nationalities: Set? = null,
183 | val address: AddressComplex? = null
184 | )
185 |
186 | @Serializable
187 | private data class Verification(
188 | @SerialName("trust_framework") val trustFramwork: String? = null,
189 | val time: String? = null,
190 | @SerialName("verification_process") val verificationProcess: String? = null,
191 | val evidence: Set? = null
192 | )
193 |
194 | @Serializable
195 | private data class VerifiedClaims(
196 | val verification: Verification? = null,
197 | val claims: Claims? = null
198 | )
199 |
200 | @Serializable
201 | private data class ComplexCredential(
202 | val iss: String,
203 | @SerialName("verified_claims") val verifiedClaims: VerifiedClaims? = null,
204 | @SerialName("birth_middle_name") val birthMiddleName: String? = null,
205 | val salutation: String? = null,
206 | val msisdn: String? = null
207 | )
208 |
209 | @Test
210 | internal fun complexTest() {
211 | val testConfig = TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, holderSigner, "Complex Test")
212 | val claims = ComplexCredential(
213 | iss = issuer,
214 | verifiedClaims = VerifiedClaims(
215 | verification = Verification(
216 | trustFramwork = "de_aml",
217 | time = "2012-04-23T18:25Z",
218 | verificationProcess = "f24c6f-6d3f-4ec5-973e-b0d8506f3bc7",
219 | evidence = setOf(
220 | Evidence(
221 | type = "document",
222 | method = "pipp",
223 | time = "2012-04-22T11:30Z",
224 | document = Document(
225 | type = "idcard",
226 | issuer = Issuer(name = "Stadt Augsburg", country = "DE"),
227 | number = "53554554",
228 | dateOfIssuance = "2010-03-23",
229 | dataOfExpiry = "2020-03-22"
230 | )
231 | )
232 | ),
233 | ),
234 | claims = Claims(
235 | givenName = "Max",
236 | familyName = "Meier",
237 | birthdate = "1956-01-28",
238 | placeOfBirth = PlaceOfBirth(country = "DE", locality = "Musterstadt"),
239 | nationalities = setOf("DE"),
240 | address = AddressComplex(
241 | locality = "Maxstadt",
242 | postalCode = "12344",
243 | country = "DE",
244 | streetAddress = "An der Weide 22"
245 | )
246 | )
247 | ),
248 | birthMiddleName = "Timotheus",
249 | salutation = "Dr.",
250 | msisdn = "49123456789"
251 | )
252 | val discloseStructure = ComplexCredential(
253 | iss = "",
254 | verifiedClaims = VerifiedClaims(
255 | verification = Verification(evidence = setOf(Evidence(document = Document(issuer = Issuer())))),
256 | claims = Claims(placeOfBirth = PlaceOfBirth())
257 | )
258 | )
259 | val releaseClaims = ComplexCredential(
260 | iss = "",
261 | verifiedClaims = VerifiedClaims(
262 | verification = Verification(trustFramwork = "", time = "", evidence = setOf(Evidence(type = ""))),
263 | claims = Claims(
264 | givenName = "",
265 | familyName = "",
266 | birthdate = "",
267 | placeOfBirth = PlaceOfBirth(country = "")
268 | )
269 | )
270 | )
271 | val expectedClaims = ComplexCredential(
272 | iss = issuer,
273 | VerifiedClaims(
274 | verification = Verification(
275 | trustFramwork = "de_aml",
276 | time = "2012-04-23T18:25Z",
277 | evidence = setOf(Evidence(type = "document", document = Document(issuer = Issuer())))
278 | ),
279 | claims = Claims(
280 | givenName = "Max",
281 | familyName = "Meier",
282 | birthdate = "1956-01-28",
283 | placeOfBirth = PlaceOfBirth(country = "DE")
284 | )
285 | )
286 | )
287 |
288 | val expectedClaimsKeys =
289 | listOf("trust_framework", "time", "type", "given_name", "family_name", "birthdate", "country")
290 |
291 | testRoutine(
292 | expectedClaimsKeys,
293 | expectedClaims,
294 | claims,
295 | discloseStructure,
296 | releaseClaims,
297 | testConfig
298 | )
299 | }
300 |
301 | @Serializable
302 | private data class RecursiveVerifiedClaims(
303 | val verification: Verification? = null,
304 | @SerialName(HIDE_NAME + "claims") val claims: Claims? = null
305 | )
306 |
307 | @Serializable
308 | private data class ComplexRecursiveCredential(
309 | val iss: String,
310 | @SerialName("verified_claims") val verifiedClaims: RecursiveVerifiedClaims? = null,
311 | @SerialName("birth_middle_name") val birthMiddleName: String? = null,
312 | val salutation: String? = null,
313 | val msisdn: String? = null
314 | )
315 |
316 | @Test
317 | internal fun complexRecursiveTest() {
318 | val testConfig = TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, holderSigner, "Recursive Complex Test")
319 | val claims = ComplexRecursiveCredential(
320 | iss = issuer,
321 | verifiedClaims = RecursiveVerifiedClaims(
322 | verification = Verification(
323 | trustFramwork = "de_aml",
324 | time = "2012-04-23T18:25Z",
325 | verificationProcess = "f24c6f-6d3f-4ec5-973e-b0d8506f3bc7",
326 | evidence = setOf(
327 | Evidence(
328 | type = "document",
329 | method = "pipp",
330 | time = "2012-04-22T11:30Z",
331 | document = Document(
332 | type = "idcard",
333 | issuer = Issuer(name = "Stadt Augsburg", country = "DE"),
334 | number = "53554554",
335 | dateOfIssuance = "2010-03-23",
336 | dataOfExpiry = "2020-03-22"
337 | )
338 | )
339 | ),
340 | ),
341 | claims = Claims(
342 | givenName = "Max",
343 | familyName = "Meier",
344 | birthdate = "1956-01-28",
345 | placeOfBirth = PlaceOfBirth(country = "DE", locality = "Musterstadt"),
346 | nationalities = setOf("DE"),
347 | address = AddressComplex(
348 | locality = "Maxstadt",
349 | postalCode = "12344",
350 | country = "DE",
351 | streetAddress = "An der Weide 22"
352 | )
353 | )
354 | ),
355 | birthMiddleName = "Timotheus",
356 | salutation = "Dr.",
357 | msisdn = "49123456789"
358 | )
359 | val discloseStructure = ComplexRecursiveCredential(
360 | iss = "",
361 | verifiedClaims = RecursiveVerifiedClaims(
362 | verification = Verification(evidence = setOf(Evidence(document = Document(issuer = Issuer())))),
363 | claims = Claims(placeOfBirth = PlaceOfBirth())
364 | )
365 | )
366 | val releaseClaims = ComplexRecursiveCredential(
367 | iss = "",
368 | verifiedClaims = RecursiveVerifiedClaims(
369 | verification = Verification(trustFramwork = "", time = "", evidence = setOf(Evidence(type = ""))),
370 | claims = Claims(
371 | givenName = "",
372 | familyName = "",
373 | birthdate = "",
374 | placeOfBirth = PlaceOfBirth(country = "")
375 | )
376 | )
377 | )
378 | val expectedClaims = ComplexRecursiveCredential(
379 | iss = issuer,
380 | RecursiveVerifiedClaims(
381 | verification = Verification(
382 | trustFramwork = "de_aml",
383 | time = "2012-04-23T18:25Z",
384 | evidence = setOf(Evidence(type = "document", document = Document(issuer = Issuer())))
385 | ),
386 | claims = Claims(
387 | givenName = "Max",
388 | familyName = "Meier",
389 | birthdate = "1956-01-28",
390 | placeOfBirth = PlaceOfBirth(country = "DE")
391 | )
392 | )
393 | )
394 |
395 | val expectedClaimsKeys =
396 | listOf("trust_framework", "time", "type", "given_name", "family_name", "birthdate", "country", "claims")
397 |
398 | testRoutine(
399 | expectedClaimsKeys,
400 | expectedClaims,
401 | claims,
402 | discloseStructure,
403 | releaseClaims,
404 | testConfig
405 | )
406 | }
407 | }
408 |
--------------------------------------------------------------------------------
/src/test/kotlin/org/sd_jwt/Debugging.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.JOSEObjectType
4 | import com.nimbusds.jose.jwk.Curve
5 | import com.nimbusds.jose.jwk.OctetKeyPair
6 | import com.nimbusds.jose.jwk.gen.ECKeyGenerator
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 | import org.junit.jupiter.api.Test
10 | import java.time.Instant
11 | import java.util.*
12 |
13 | internal class Debugging {
14 | private val verifier = "http://verifier.example.com"
15 | private val issuer = "http://issuer.example.com"
16 |
17 | private val issuerKeyJson =
18 | """{"kty":"OKP","d":"Pp1foKt6rJAvx0igrBEfOgrT0dgMVQDHmgJZbm2h518","crv":"Ed25519","kid":"IssuerKey","x":"1NYF4EFS2Ov9hqt35fVt2J-dktLV29hs8UFjxbOXnho"}"""
19 | private val issuerKey = OctetKeyPair.parse(issuerKeyJson)
20 | private val issuerSigner = KeyBasedSdJwtSigner(issuerKey)
21 | private val holderKeyJson =
22 | """{"kty":"OKP","d":"8G6whDz1owU1k7-TqtP3xEMasdI3t3j2AvpvXVwwrHQ","crv":"Ed25519","kid":"HolderKey","x":"s6gVLINLcCGhGEDTf_v1zMluLZcXj4GOXAfQlOWZM9Q"}"""
23 | private val holderKey = OctetKeyPair.parse(holderKeyJson)
24 | private val holderSigner = KeyBasedSdJwtSigner(holderKey)
25 |
26 | private val trustedIssuers = mutableMapOf(issuer to issuerKey.toPublicJWK().toJSONString())
27 |
28 | private val nonce = "12345"
29 |
30 | @Serializable
31 | private data class Address(
32 | @SerialName("street_address") val streetAddress: String? = null,
33 | val locality: String? = null,
34 | val region: String? = null,
35 | val country: String? = null,
36 | @SerialName("zip_code") val zipCode: Int? = null
37 | )
38 |
39 | @Serializable
40 | private data class IdCredential(
41 | val iss: String,
42 | @SerialName("given_name") val givenName: String? = null,
43 | @SerialName("family_name") val familyName: String? = null,
44 | val email: String? = null,
45 | val birthday: String? = null,
46 | val nicknames: Set? = null,
47 | @SerialName (HIDE_NAME + "address") val address: Address? = null,
48 | @SerialName("secret_club_membership") val secretClubMembership: String? = null
49 | )
50 |
51 | @Test
52 | fun debugging() {
53 | val claims = IdCredential(
54 | iss = issuer,
55 | givenName = "Alice",
56 | familyName = "Wonderland",
57 | email = "alice@example.com",
58 | birthday = "1950-01-01",
59 | nicknames = setOf("A", "B"),
60 | address = Address(
61 | streetAddress = "123 Main St",
62 | locality = "Anytown",
63 | region = "Anystate",
64 | country = "US",
65 | zipCode = 123456
66 | ),
67 | secretClubMembership = "SecretClub"
68 | )
69 |
70 | val discloseStructure = IdCredential(iss = "", address = Address())
71 |
72 | val holderPubKey = holderKey?.toPublicJWK()
73 |
74 | val credentialGen = createCredential(claims, issuerSigner, holderPubKey, discloseStructure)
75 |
76 | println("====================== Issuer ======================")
77 | println("Generated credential: $credentialGen")
78 |
79 | val releaseClaims = IdCredential(
80 | iss = "",
81 | givenName = "",
82 | email = "",
83 | address = Address(streetAddress = "", zipCode = 0),
84 | secretClubMembership = ""
85 | )
86 |
87 | val presentationGen = createPresentation(credentialGen, releaseClaims, verifier, nonce, holderSigner)
88 |
89 | println("====================== Wallet ======================")
90 | println("Generated presentation: $presentationGen")
91 |
92 | val verifiedCredentialGen =
93 | verifyPresentation(presentationGen, TrustedIssuersSdJwtVerifier(trustedIssuers), nonce, verifier, true)
94 |
95 | println("===================== Verifier =====================")
96 | println("Verified credential: $verifiedCredentialGen\n")
97 | }
98 |
99 |
100 | @Serializable
101 | private data class SimpleTestCredential(
102 | val iss: String,
103 | @SerialName("given_name") val givenName: String? = null,
104 | @SerialName("family_name") val familyName: String? = null,
105 | val email: String? = null,
106 | val b: Boolean? = null,
107 | val age: Int? = null
108 | )
109 |
110 | @Test
111 | fun minimal() {
112 | val claims = SimpleTestCredential(iss = issuer, "Alice", "Wonderland", "alice@example.com", false, 21)
113 | val discloseStructure = SimpleTestCredential(iss = "")
114 | val credential = createCredential(claims, issuerSigner, discloseStructure = discloseStructure)
115 |
116 | println("====================== Issuer ======================")
117 | println("Credential: $credential")
118 |
119 | val releaseClaims =
120 | SimpleTestCredential(iss = "", givenName = "", email = "", age = 0) // Non-null claims will be revealed
121 | val presentation = createPresentation(credential, releaseClaims)
122 |
123 | println("====================== Wallet ======================")
124 | println("Presentation: $presentation")
125 |
126 | val verifiedSimpleTestCredential =
127 | verifyPresentation(presentation, TrustedIssuersSdJwtVerifier(trustedIssuers), verifyHolderBinding = false)
128 |
129 | println("===================== Verifier =====================")
130 | println("Verified credential: $verifiedSimpleTestCredential\n")
131 | }
132 |
133 | @Serializable
134 | data class CredentialSubject(
135 | @SerialName("given_name") val givenName: String? = null,
136 | @SerialName("family_name") val familyName: String? = null,
137 | val email: String? = null
138 | )
139 |
140 | @Serializable
141 | data class EmailCredential(
142 | val type: String,
143 | val iat: Long,
144 | val exp: Long,
145 | val iss: String,
146 | @SerialName(HIDE_NAME + "credentialSubject") val credentialSubject: CredentialSubject? = null
147 | )
148 |
149 | @Test
150 | fun nextcloudLoginCredential() {
151 | val issuerKey = ECKeyGenerator(Curve.P_256)
152 | .keyID("Issuer")
153 | .generate()
154 |
155 | val header = SdJwtHeader(JOSEObjectType("vc+sd-jwt"), "credential-claims-set+json")
156 | val signer = KeyBasedSdJwtSigner(issuerKey, sdJwtHeader = header)
157 |
158 | val holderKey = ECKeyGenerator(Curve.P_256)
159 | .keyID("Holder")
160 | .generate()
161 |
162 | val issuer = "did:jwk:${b64Encoder(issuerKey.toPublicJWK().toJSONString())}"
163 |
164 | val trustedIssuers = mapOf(issuer to issuerKey.toPublicJWK().toJSONString())
165 |
166 | val userClaims = EmailCredential(
167 | type = "VerifiedEMail",
168 | iat = Date.from(Instant.now()).time / 1000,
169 | exp = Date.from(Instant.now().plusSeconds(3600 * 48)).time / 1000,
170 | iss = issuer,
171 | credentialSubject = CredentialSubject(
172 | givenName = "Alice",
173 | familyName = "Wonderland",
174 | email = "alice@example.com"
175 | )
176 | )
177 |
178 | val discloseStructure =
179 | EmailCredential(type = "", iat = 0, exp = 0, iss = "", credentialSubject = CredentialSubject())
180 |
181 | val credential = createCredential(userClaims, signer, holderKey.toPublicJWK(), discloseStructure)
182 |
183 | println("Credential: $credential")
184 | println()
185 |
186 | val releaseClaims = EmailCredential(type = "", iat = 0, exp = 0, iss = "", credentialSubject = CredentialSubject(email = "", givenName = "", familyName = ""))
187 | val holderSignerNextcloud = KeyBasedSdJwtSigner(holderKey)
188 | val presentation =
189 | createPresentation(credential, releaseClaims, "https://nextcloud.example.com", "1234", holderSignerNextcloud)
190 | println("Presentation: $presentation")
191 | println()
192 |
193 | val verifiedEmailCredential = verifyPresentation(
194 | presentation,
195 | TrustedIssuersSdJwtVerifier(trustedIssuers),
196 | "1234",
197 | "https://nextcloud.example.com",
198 | true
199 | )
200 | println(verifiedEmailCredential)
201 | }
202 | }
--------------------------------------------------------------------------------
/src/test/kotlin/org/sd_jwt/SdJwtKtJSONClaimsTest.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.jwk.OctetKeyPair
4 | import org.json.JSONObject
5 | import org.junit.jupiter.api.Test
6 |
7 | class SdJwtKtJSONClaimsTest {
8 | private val verifier = "http://verifier.example.com"
9 | private val issuer = "http://issuer.example.com"
10 |
11 | private val issuerKeyJson =
12 | """{"kty":"OKP","d":"Pp1foKt6rJAvx0igrBEfOgrT0dgMVQDHmgJZbm2h518","crv":"Ed25519","kid":"IssuerKey","x":"1NYF4EFS2Ov9hqt35fVt2J-dktLV29hs8UFjxbOXnho"}"""
13 | private val issuerKey = OctetKeyPair.parse(issuerKeyJson)
14 | private val issuerSigner = KeyBasedSdJwtSigner(issuerKey)
15 | private val holderKeyJson =
16 | """{"kty":"OKP","d":"8G6whDz1owU1k7-TqtP3xEMasdI3t3j2AvpvXVwwrHQ","crv":"Ed25519","kid":"HolderKey","x":"s6gVLINLcCGhGEDTf_v1zMluLZcXj4GOXAfQlOWZM9Q"}"""
17 | private val holderKey = OctetKeyPair.parse(holderKeyJson)
18 | private val holderSigner = KeyBasedSdJwtSigner(holderKey)
19 |
20 | private val trustedIssuers = mutableMapOf(issuer to issuerKey.toPublicJWK().toJSONString())
21 |
22 | private val nonce = "12345"
23 |
24 | private val testConfig =
25 | TestConfig(
26 | trustedIssuers = trustedIssuers,
27 | issuerSigner = issuerSigner,
28 | issuer = issuer,
29 | verifier = verifier,
30 | nonce = nonce,
31 | holderSigner = holderSigner,
32 | name = "JSONObject Credential"
33 | )
34 |
35 |
36 | private val simpleCredentialUserClaims = JSONObject(
37 | mapOf(
38 | Pair("iss", "$issuer"),
39 | Pair("iat", "54678098"),
40 | Pair("first_name", "Max"),
41 | Pair("last_name", "Muster"),
42 | Pair("age", "33"),
43 | Pair("address", "Musterstr. 15, 75759, DE")
44 | )
45 | )
46 |
47 | private val complexCredentialUserClaims = JSONObject(
48 | mapOf(
49 | Pair("iss", "$issuer"),
50 | Pair("iat", "54678098"),
51 | Pair("first_name", "Max"),
52 | Pair("last_name", "Muster"),
53 | Pair("age", "33"),
54 | Pair(
55 | "address", mapOf(
56 | Pair("street", "Musterstr. 15"),
57 | Pair("code", "75759"),
58 | Pair("country", "DE")
59 | )
60 | )
61 | )
62 | )
63 |
64 | @Test
65 | fun createSimpleCredentialAsJson_partly_disclosed_ok() {
66 | val discloseStructure = JSONObject(
67 | mapOf(
68 | Pair("iss", ""),
69 | Pair("iat", "")
70 | )
71 | )
72 |
73 | val releaseClaims = JSONObject(
74 | mapOf(
75 | Pair("iss", ""),
76 | Pair("first_name", ""),
77 | Pair("age", "")
78 | )
79 | )
80 |
81 | val expectedClaims = JSONObject(
82 | mapOf(
83 | Pair("iss", "$issuer"),
84 | Pair("first_name", "Max"),
85 | Pair("age", "33")
86 | )
87 | )
88 |
89 | val expectedClaimsKeys = listOf(
90 | "first_name",
91 | "age",
92 | )
93 |
94 | testRoutine(
95 | expectedClaimsKeys = expectedClaimsKeys,
96 | expectedClaims = expectedClaims,
97 | claims = simpleCredentialUserClaims,
98 | discloseStructure = discloseStructure,
99 | releaseClaims = releaseClaims,
100 | testConfig = testConfig,
101 | compareSingleValues = true
102 | )
103 | }
104 |
105 | @Test
106 | fun shouldIgnoreExtraClaimInDisclosureStructure() {
107 | val discloseStructure = JSONObject(
108 | mapOf(
109 | Pair("iss", ""),
110 | Pair("iat", ""),
111 | Pair("extra", "")
112 | )
113 | )
114 |
115 | val releaseClaims = JSONObject(
116 | mapOf(
117 | Pair("iss", ""),
118 | Pair("first_name", ""),
119 | Pair("age", "")
120 | )
121 | )
122 |
123 | val expectedClaims = JSONObject(
124 | mapOf(
125 | Pair("iss", "$issuer"),
126 | Pair("iat", "54678098"),
127 | Pair("first_name", "Max"),
128 | Pair("age", "33")
129 | )
130 | )
131 |
132 | val expectedClaimsKeys = listOf(
133 | "first_name",
134 | "age",
135 | )
136 |
137 | testRoutine(
138 | expectedClaimsKeys = expectedClaimsKeys,
139 | expectedClaims = expectedClaims,
140 | claims = simpleCredentialUserClaims,
141 | discloseStructure = discloseStructure,
142 | releaseClaims = releaseClaims,
143 | testConfig = testConfig,
144 | compareSingleValues = true
145 | )
146 | }
147 |
148 | @Test
149 | fun complexStructureShouldIgnoreExtraClaimInDisclosureStructure() {
150 | val discloseStructure = JSONObject(
151 | mapOf(
152 | Pair("iss", ""),
153 | Pair("iat", ""),
154 | Pair(
155 | "address", mapOf(
156 | Pair("country", ""),
157 | Pair("extra", "")
158 | )
159 | )
160 | )
161 | )
162 |
163 | val releaseClaims = JSONObject(
164 | mapOf(
165 | Pair("first_name", ""),
166 | Pair("age", "")
167 | )
168 | )
169 |
170 | val expectedClaims = JSONObject(
171 | mapOf(
172 | Pair("iss", "$issuer"),
173 | Pair("iat", "54678098"),
174 | Pair("first_name", "Max"),
175 | Pair("age", "33"),
176 | Pair("address", mapOf(
177 | Pair("country", "DE")
178 | ))
179 | )
180 | )
181 |
182 | val expectedClaimsKeys = listOf(
183 | "first_name",
184 | "age",
185 | )
186 |
187 | testRoutine(
188 | expectedClaimsKeys = expectedClaimsKeys,
189 | expectedClaims = expectedClaims,
190 | claims = complexCredentialUserClaims,
191 | discloseStructure = discloseStructure,
192 | releaseClaims = releaseClaims,
193 | testConfig = testConfig,
194 | compareSingleValues = true
195 | )
196 | }
197 | }
--------------------------------------------------------------------------------
/src/test/kotlin/org/sd_jwt/SdJwtKtTest.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import com.nimbusds.jose.jwk.OctetKeyPair
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import org.junit.jupiter.api.Test
7 | import org.junit.jupiter.api.assertThrows
8 |
9 | internal class SdJwtKtTest {
10 |
11 | @Serializable
12 | private data class SimpleTestCredential(
13 | val iss: String,
14 | @SerialName("given_name") val givenName: String? = null,
15 | @SerialName("family_name") val familyName: String? = null,
16 | val email: String? = null,
17 | val b: Boolean? = null,
18 | val age: Int? = null
19 | )
20 |
21 | private val verifier = "http://verifier.example.com"
22 | private val issuer = "http://issuer.example.com"
23 |
24 | private val issuerKeyJson =
25 | """{"kty":"OKP","d":"Pp1foKt6rJAvx0igrBEfOgrT0dgMVQDHmgJZbm2h518","crv":"Ed25519","kid":"IssuerKey","x":"1NYF4EFS2Ov9hqt35fVt2J-dktLV29hs8UFjxbOXnho"}"""
26 | private val issuerKey = OctetKeyPair.parse(issuerKeyJson)
27 | private val issuerSigner = KeyBasedSdJwtSigner(issuerKey)
28 | private val holderKeyJson =
29 | """{"kty":"OKP","d":"8G6whDz1owU1k7-TqtP3xEMasdI3t3j2AvpvXVwwrHQ","crv":"Ed25519","kid":"HolderKey","x":"s6gVLINLcCGhGEDTf_v1zMluLZcXj4GOXAfQlOWZM9Q"}"""
30 | private val holderKey = OctetKeyPair.parse(holderKeyJson)
31 | private val holderSigner = KeyBasedSdJwtSigner(holderKey)
32 |
33 | private val trustedIssuers = mutableMapOf(issuer to issuerKey.toPublicJWK().toJSONString())
34 |
35 | private val nonce = "12345"
36 |
37 | @Test
38 | fun testSimpleCredentialWithNonceAud() {
39 | val testConfig = TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, null, "Simple Credential With Aud and Nonce")
40 |
41 | val claims = SimpleTestCredential(issuer,"Alice", "Wonderland", "alice@example.com", false, 21)
42 | val discloseStructure = SimpleTestCredential(iss = "")
43 | val releaseClaims = SimpleTestCredential(iss = "", givenName = "", email = "", age = 0)
44 | val expectedClaims = SimpleTestCredential(iss = issuer, givenName = "Alice", email = "alice@example.com", age = 21)
45 |
46 | val expectedClaimsKeys = listOf("given_name", "email", "age")
47 |
48 | testRoutine(expectedClaimsKeys, expectedClaims, claims, discloseStructure, releaseClaims, testConfig)
49 | }
50 |
51 | @Test
52 | fun testSimpleCredential() {
53 | val testConfig = TestConfig(trustedIssuers, issuerSigner, issuer, null, null, null, "Simple Credential")
54 |
55 | val claims = SimpleTestCredential(issuer, "Alice", "Wonderland", "alice@example.com", false, 21)
56 | val discloseStructure = SimpleTestCredential(iss = "")
57 | val releaseClaims = SimpleTestCredential(iss = "", givenName = "", email = "", age = 0)
58 | val expectedClaims = SimpleTestCredential(iss = issuer, givenName = "Alice", email = "alice@example.com", age = 21)
59 |
60 | val expectedClaimsKeys = listOf("given_name", "email", "age")
61 |
62 | testRoutine(expectedClaimsKeys, expectedClaims, claims, discloseStructure, releaseClaims, testConfig)
63 | }
64 |
65 | @Serializable
66 | private data class Address(
67 | @SerialName("street_address") val streetAddress: String? = null,
68 | val locality: String? = null,
69 | val region: String? = null,
70 | val country: String? = null,
71 | @SerialName("zip_code") val zipCode: Int? = null
72 | )
73 |
74 | @Serializable
75 | private data class IdCredential(
76 | val iss: String,
77 | @SerialName("given_name") val givenName: String? = null,
78 | @SerialName("family_name") val familyName: String? = null,
79 | val email: String? = null,
80 | val birthday: String? = null,
81 | val nicknames: Set? = null,
82 | val address: Address? = null
83 | )
84 |
85 | @Test
86 | fun testAdvancedCredential() {
87 | val testConfig =
88 | TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, holderSigner, "Advanced Credential")
89 |
90 | val claims = IdCredential(
91 | issuer,
92 | "Alice",
93 | "Wonderland",
94 | "alice@example.com",
95 | "1940-01-01",
96 | setOf("A", "B"),
97 | Address("123 Main St", "Anytown", "Anystate", "US", 123456)
98 | )
99 | val discloseStructure = IdCredential(iss = "")
100 | val releaseClaims = IdCredential(iss = "", givenName = "", familyName = "", nicknames = setOf(), address = Address())
101 | val expectedClaims = IdCredential(
102 | iss = issuer,
103 | givenName = "Alice",
104 | familyName = "Wonderland",
105 | nicknames = setOf("A", "B"),
106 | address = Address("123 Main St", "Anytown", "Anystate", "US", 123456)
107 | )
108 | val expectedClaimsKeys = listOf("given_name", "family_name", "nicknames", "address")
109 |
110 | testRoutine(expectedClaimsKeys, expectedClaims, claims, discloseStructure, releaseClaims, testConfig)
111 | }
112 |
113 | @Test
114 | fun testAdvancedCredentialStructured() {
115 | val testConfig =
116 | TestConfig(trustedIssuers, issuerSigner, issuer, verifier, nonce, holderSigner, "Advanced Credential Structured")
117 | val claims = IdCredential(
118 | issuer,
119 | "Alice",
120 | "Wonderland",
121 | "alice@example.com",
122 | "1940-01-01",
123 | setOf("A", "B"),
124 | Address("123 Main St", "Anytown", "Anystate", "US", 123456)
125 | )
126 | val discloseStructure = IdCredential(iss = "", address = Address())
127 | val releaseClaims = IdCredential(
128 | iss = "",
129 | givenName = "",
130 | familyName = "",
131 | nicknames = setOf(),
132 | address = Address(streetAddress = "", locality = "", zipCode = 0)
133 | )
134 | val expectedClaims = IdCredential(
135 | iss = issuer,
136 | givenName = "Alice",
137 | familyName = "Wonderland",
138 | nicknames = setOf("A", "B"),
139 | address = Address(streetAddress = "123 Main St", locality = "Anytown", zipCode = 123456)
140 | )
141 |
142 | val expectedClaimsKeys = listOf(
143 | "given_name",
144 | "family_name",
145 | "nicknames",
146 | "street_address",
147 | "locality",
148 | "zip_code"
149 | )
150 |
151 | testRoutine(
152 | expectedClaimsKeys,
153 | expectedClaims,
154 | claims,
155 | discloseStructure,
156 | releaseClaims,
157 | testConfig
158 | )
159 | }
160 |
161 | @Test
162 | fun noneHeader() {
163 | // eyJhbGciOiJub25lIn0. => {"alg":"none"}
164 |
165 | // None header in SD-JWT
166 | val noneHeaderSdJwtPresentation =
167 | "eyJhbGciOiJub25lIn0.eyJfc2QiOlsiU19wU3k4SHNPbTVvajJjWlkzMDV0X21lcTBtZ05Hb29mcVpIbUptN200ZyIsInczVnZ6NDBxeUExMG5WY2t1VUgtNjYwZ25DZXlqWHFWV25wckljQ3Q5cFkiLCJ4VE1URWttRkNyRk5YR1o0OHJkOTZobXdGY0ZJblVUTm82eWNYLUlxNEFrIiwiZFozWTJhcVBORE5xc21CWENiQVFWTnRGcHJMSnJ1VUlfYUtuOVdHWHd0QSIsIjZKdFBLdHQ0WW5CVm5LV3RjRlBpUE8xZFpLNS0xMTR0TWtjWDJyUWMzOUkiLCI0U1RVMzdpZ3loNVdPV3p1X2lBWkVaVHVWbkhxakI5Z2ZoWEpTeU1IUkFZIiwiYkhkeXJ4bjVjVTk4UHFGNjFaUU9qR2Q1ZXd2UzhKU3BXLVRHU0xmZS1aSSJdLCJhZGRyZXNzIjp7Il9zZCI6WyJwSEZ3UjRiQk1iTm53dWFQX1g4ekFzNHhzTmZxZHVIcWxRX25MNDRic01vIiwiSFlidElYNUpKeHpsMVpTQjB6YUtEam9ETHVmRG1MYXVnemF5RnZfa1NRbyIsIldrbzZTQl9QU2VDUHJWdjlidkl5dW5rUFdTc1NqTVd2N2FsUmcyZGFrRzgiLCJjdWJUOE9qbTduZzBXWDVVaDJCaWpUcjZNVkVYRDhCR3p0VEd6LUtOMGhvIiwiaWhORV9mcDRWMnRFSTllcmlEZGNmZmN1OUdIb2FicnBRYk04VDZSeFF2VSIsIkYtR3h4MTJnVTJfVl9vQXppUUFjd1BGVlgwNVJwa1VCamhMWVd4YzlZeFkiLCJ0YWxHOXEycktBdHI1UmVSWnpGSzBKSDdiMWVTY1pzTURmdjl5MVlwM2w0IiwiM0dyd0VZclZwSnJSc2NwTnJUZ0VoT0pZQnowOWE3U2Rwbk04Q1JRZUgzRSJdfSwiX3NkX2FsZyI6InNoYS0yNTYiLCJpc3MiOiJodHRwOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwiY25mIjp7Imp3ayI6eyJ4IjoiczZnVkxJTkxjQ0doR0VEVGZfdjF6TWx1TFpjWGo0R09YQWZRbE9XWk05USIsImt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJIb2xkZXJLZXkifX0sImV4cCI6MTY3NjM4NzcwOCwiaWF0IjoxNjc2MzAxMzA4fQ.rbdu6JOwbI1TseTVk8-HDRKHebLtliNK9-RoOCkykExL3VafDnbLFX0lSZmIX0fTtGNLU3HlG3cIXj2JBW4XCg~WyJ5bFlVRXRKeThBZy0xdVV5X0E1VzV3IiwiZmFtaWx5X25hbWUiLCJXb25kZXJsYW5kIl0~WyJ0elN0SGJncFFlaF8ydVZTWUlqc01nIiwiZ2l2ZW5fbmFtZSIsIkFsaWNlIl0~WyJwekdKS21QblgySzJLREV3XzF5ZlBnIiwibmlja25hbWVzIixbIkEiLCJCIl1d~WyI2RWlHSjkyUGh5bnJ4UmpybFAybGp3Iiwic3RyZWV0X2FkZHJlc3MiLCIxMjMgTWFpbiBTdCJd~WyJ0cXV1OC04YXVvS0ZBWHpRenVfZlJ3IiwibG9jYWxpdHkiLCJBbnl0b3duIl0~WyI2XzVkckp4R3JZb1pIejJMT01GVS13IiwiemlwX2NvZGUiLDEyMzQ1Nl0~eyJraWQiOiJIb2xkZXJLZXkiLCJhbGciOiJFZERTQSJ9.eyJhdWQiOiJodHRwOi8vdmVyaWZpZXIuZXhhbXBsZS5jb20iLCJub25jZSI6IjEyMzQ1IiwiaWF0IjoxNjc2MzAxMzA4fQ.AA1r68cEsVkJ12wSoDw5_SYDU9pR4PJbU2eKN_LzwsPkaIq9Ho8ScNcwK8W9C09wrWsIM8pGP7Y6Ntp-8R5iCQ"
168 | assertThrows {
169 | verifyPresentation(noneHeaderSdJwtPresentation, TrustedIssuersSdJwtVerifier(trustedIssuers), nonce, verifier, true)
170 | }
171 |
172 | // None header in holder binding JWT
173 | val noneHeaderHolderBindingPresentation = "eyJraWQiOiJJc3N1ZXJLZXkiLCJhbGciOiJFZERTQSJ9.eyJfc2QiOlsiV2FvT0tOV2VMQXhaYlVfbldTZzVXbWZZNTBHZHhwSmRtbHRfZkl2S3QtRSIsIkhoZ0RLZzAtbFU5dGJUdV9ORWxVZHZvWm53cU9mUnlZQ0wwMEZzajdTdWMiLCIwLWNRanVXZk5jZjNaVjJTT2lrYWVoWkozSEU1UXNNLTh5a0NncjUyb2MwIiwiV0N4WGQ3TmRadGRJb3FTU1owWEtJbEVZaUVrY1RkZ3VtRHpkcDNWc1c4dyIsIm1ldUhXcnZoNUxZYVZpbUc4SUxRZmtFaS1vTTVfMzZJQzZ6Z19QN19JV0kiLCIyTF9EcVpsZUhETS1oeks1eENtRy02MDRreEFnVDl0N3pUbmZ0Yk5TbTJzIiwiQmJsQXFBeG5qdmFMenFLR0hQRDd0cWJRUk5FQmFPYXNZZVF4VW41OGJhNCIsIkdaNmh6RnFwd25hZnhrQTMtTGs5dHVleE9PQ3d1TU1JWGVETEZrLVgyMFUiLCJRVklBdzIxUFRncnlIMU0yd0RINjZzeldULWZyRnU4WU5qNGJMSkpEa3ZVIiwiaFZ5RlNQR1FPSWhWeWpIT0c4YVNsTl9QbWlvNng5QVhydkRndUdwQmJSZyJdLCJfc2RfYWxnIjoic2hhLTI1NiIsImlzcyI6Imh0dHA6Ly9pc3N1ZXIuZXhhbXBsZS5jb20iLCJjbmYiOnsiandrIjp7IngiOiJzNmdWTElOTGNDR2hHRURUZl92MXpNbHVMWmNYajRHT1hBZlFsT1daTTlRIiwia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsImtpZCI6IkhvbGRlcktleSJ9fSwiZXhwIjoxNjc2NDc5ODgyLCJpYXQiOjE2NzYzOTM0ODJ9.Zn09th3WkaQdyFim0OElUutofO-cohyH-dG-ElJGUq-YWSe71ONAoxh_9t3wNxMWihbdqlSMpcdje7QqDKMnAg~WyJGSDZUNTdPNXh0bnZQUjB4dTh6RHlRIiwiZ2l2ZW5fbmFtZSIsIkFsaWNlIl0~WyJCbzdaZm5XVVlkNkxxVm1mOE5iaEhnIiwiZmFtaWx5X25hbWUiLCJXb25kZXJsYW5kIl0~WyJhYVVQUWZoakxQU1V0eXBLLWZPLVFnIiwibmlja25hbWVzIixbIkEiLCJCIl1d~WyJ0anBKX0VaanM2dEEzWXR6UndweEF3IiwiYWRkcmVzcyIseyJzdHJlZXRfYWRkcmVzcyI6IjEyMyBNYWluIFN0IiwiY291bnRyeSI6IlVTIiwibG9jYWxpdHkiOiJBbnl0b3duIiwicmVnaW9uIjoiQW55c3RhdGUiLCJ6aXBfY29kZSI6MTIzNDU2fV0~eyJhbGciOiJub25lIn0.eyJhdWQiOiJodHRwOi8vdmVyaWZpZXIuZXhhbXBsZS5jb20iLCJub25jZSI6IjEyMzQ1IiwiaWF0IjoxNjc2MzkzNDgyfQ.KbPZidegP_0FpuNJgl0SZ0PPXGahQSszARPvOKxhj3jh4aejJOpWe3C9aYIyZtZI_Hk6Ks84ot1t30ylYjOWCg"
174 | assertThrows {
175 | verifyPresentation(noneHeaderHolderBindingPresentation, TrustedIssuersSdJwtVerifier(trustedIssuers), nonce, verifier, true)
176 | }
177 | }
178 | }
--------------------------------------------------------------------------------
/src/test/kotlin/org/sd_jwt/Utils.kt:
--------------------------------------------------------------------------------
1 | package org.sd_jwt
2 |
3 | import kotlinx.serialization.encodeToString
4 | import kotlinx.serialization.json.Json
5 | import org.json.JSONArray
6 | import org.json.JSONObject
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertTrue
9 |
10 | data class TestConfig(
11 | val trustedIssuers: Map,
12 | val issuerSigner: SdJwtSigner,
13 | val issuer: String,
14 | val verifier: String?,
15 | val nonce: String?,
16 | val holderSigner: SdJwtSigner?,
17 | val name: String
18 | )
19 |
20 | inline fun testRoutine(
21 | expectedClaimsKeys: List,
22 | expectedClaims: T,
23 | claims: T,
24 | discloseStructure: T?,
25 | releaseClaims: T,
26 | testConfig: TestConfig
27 | ) {
28 | val expectedClaimsJson = JSONObject(Json.encodeToString(expectedClaims))
29 | val claimsJson = JSONObject(Json.encodeToString(claims))
30 | val discloseStructureJson = JSONObject(Json.encodeToString(discloseStructure))
31 | val releaseClaimsJson = JSONObject(Json.encodeToString(releaseClaims))
32 |
33 | testRoutine(
34 | expectedClaimsKeys = expectedClaimsKeys,
35 | expectedClaims = expectedClaimsJson,
36 | claims = claimsJson,
37 | discloseStructure = discloseStructureJson,
38 | releaseClaims = releaseClaimsJson,
39 | testConfig = testConfig,
40 | compareSingleValues = true
41 | )
42 | }
43 |
44 | fun testRoutine(
45 | expectedClaimsKeys: List,
46 | expectedClaims: JSONObject,
47 | claims: JSONObject,
48 | discloseStructure: JSONObject,
49 | releaseClaims: JSONObject,
50 | testConfig: TestConfig,
51 | compareSingleValues: Boolean = false
52 | ) {
53 | println("\n====================================================")
54 | println(testConfig.name)
55 | println("====================================================\n")
56 |
57 | // Initialization
58 | val holderPubKey = testConfig.holderSigner?.getPublicJWK()
59 |
60 | val credentialGen = createCredential(
61 | userClaims = claims,
62 | signer = testConfig.issuerSigner,
63 | holderPubKey = holderPubKey,
64 | discloseStructure = discloseStructure
65 | )
66 |
67 | println("====================== Issuer ======================")
68 | println("Generated credential: $credentialGen")
69 |
70 | val presentationGen =
71 | createPresentation(credentialGen, releaseClaims, testConfig.verifier, testConfig.nonce, testConfig.holderSigner)
72 |
73 | println("====================== Wallet ======================")
74 | println("Generated presentation: $presentationGen")
75 |
76 | // Verify presentation
77 | checkDisclosedDisclosures(presentationGen, expectedClaimsKeys)
78 |
79 | // Raise an error if there is no holder binding, aud or nonce and the presentation does not end with a ~ character
80 | if ((holderPubKey == null && testConfig.verifier == null && testConfig.nonce == null) && !presentationGen.endsWith("~")) {
81 | throw Exception("Presentation without holder binding is missing '~' at the end")
82 | }
83 |
84 | val verifiedCredentialGen = verifyPresentation(
85 | presentationGen, TrustedIssuersSdJwtVerifier(testConfig.trustedIssuers), testConfig.nonce, testConfig.verifier,
86 | holderPubKey != null
87 | )
88 |
89 | println("===================== Verifier =====================")
90 | println("Verified credential: $verifiedCredentialGen\n")
91 |
92 | if(!compareSingleValues){
93 | // Verify parsed credential
94 | assertEquals(expectedClaims, verifiedCredentialGen)
95 | }
96 | else{
97 | // verifies 2 (unsorted) JSONObject are matching
98 | assertTrue {
99 | expectedClaims.toMap().forEach {
100 | if (verifiedCredentialGen[it.key].toString().isEmpty()) false // should have key
101 | if (verifiedCredentialGen[it.key] != it.value) false // values should match for key
102 | }
103 |
104 | true
105 | }
106 | }
107 | }
108 |
109 | fun checkDisclosedDisclosures(presentation: String, expectedClaimsKeys: List) {
110 | val presentationParts = presentation.split(SEPARATOR)
111 | assertEquals(expectedClaimsKeys.size, presentationParts.size - 2)
112 | for (disclosure in presentationParts.subList(1, presentationParts.size - 1)) {
113 | val disclosureJson = JSONArray(b64Decode(disclosure))
114 | if (!expectedClaimsKeys.contains(disclosureJson[1])) {
115 | throw Exception("Unexpected disclosure: $disclosure")
116 | }
117 | }
118 | }
119 |
120 | fun booleanToInt(b: Boolean) = if (b) 1 else 0
121 |
--------------------------------------------------------------------------------