âť— Important! Before you proceed, please read the EUDI Wallet Reference Implementation project description
- Overview
- Installation
- Use cases supported
- Decoy digests
- DSL Examples
- SD-JWT VC support
- How to contribute
- License
This is a library offering a DSL (domain-specific language) for defining how a set of claims should be made selectively disclosable.
Library implements SD-JWT draft 12 is implemented in Kotlin, targeting JVM.
Library's SD-JWT DSL leverages the DSL provided by KotlinX Serialization library for defining JSON elements
// Include library in dependencies in build.gradle.kts
dependencies {
implementation("eu.europa.ec.euidw:eudi-lib-jvm-sdjwt-kt:$version")
}
- Issuance: As an Issuer use the library to issue a SD-JWT
- Holder Verification: As Holder verify a SD-JWT issued by an Issuer
- Holder Presentation: As a Holder of a SD-JWT issued by an Issuer, create a presentation for a Verifier
- Presentation Verification: As a Verifier verify SD-JWT
- Recreate initial claims: Given a SD-JWT recreate the original claims
To issue a SD-JWT, an Issuer
should have:
- Decided on how the issued claims will be selectively disclosed (check DSL examples)
- Whether to use decoy digests or not
- An appropriate signing key pair
- optionally, decided if and how to include the holder's public key in the SD-JWT
In the example below, the Issuer decides to issue an SD-JWT as follows:
- Includes in plain standard JWT claims (
sub
,iss
,iat
,exp
) - Makes selectively disclosable a claim named
address
using structured disclosure. This allows individually disclosing every subclaim ofaddress
- Uses his RSA key pair to sign the SD-JWT
val issuedSdJwt: String = runBlocking {
val issuerKeyPair = loadRsaKey("/examplesIssuerKey.json")
val sdJwtSpec = sdJwt {
claim("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c")
claim("iss", "https://example.com/issuer")
claim("iat", 1516239022)
claim("exp", 1735689661)
objClaim("address") {
sdClaim("street_address", "Schulstr. 12")
sdClaim("locality", "Schulpforta")
sdClaim("region", "Sachsen-Anhalt")
sdClaim("country", "DE")
}
}
with(NimbusSdJwtOps) {
val issuer = issuer(signer = RSASSASigner(issuerKeyPair), signAlgorithm = JWSAlgorithm.RS256)
issuer.issue(sdJwtSpec).getOrThrow().serialize()
}
}
You can get the full code here.
Tip
Please check KeyBindingTest for a more advanced issuance scenario, including adding to the SD-JWT, holder public key, to leverage key binding.
Holder
must know:
- the public key of the
Issuer
and the algorithm used by the Issuer to sign the SD-JWT
val verifiedIssuanceSdJwt: SdJwt<SignedJWT> = runBlocking {
with(NimbusSdJwtOps) {
val issuerKeyPair = loadRsaKey("/examplesIssuerKey.json")
val jwtSignatureVerifier = RSASSAVerifier(issuerKeyPair).asJwtVerifier()
val unverifiedIssuanceSdJwt = loadSdJwt("/exampleIssuanceSdJwt.txt")
verify(jwtSignatureVerifier, unverifiedIssuanceSdJwt).getOrThrow()
}
}
You can get the full code here.
In this case, a Holder
of an SD-JWT issued by an Issuer
, wants to create a presentation for a Verifier
.
The Holder
should know which of the selectively disclosed claims to include in the presentation.
The selectively disclosed claims to include in the presentation are expressed using Claim Paths as per
SD-JWT VC draft 6.
val presentationSdJwt: SdJwt<SignedJWT> = runBlocking {
with(NimbusSdJwtOps) {
val issuedSdJwt = run {
val issuerKeyPair = loadRsaKey("/examplesIssuerKey.json")
val sdJwtSpec = sdJwt {
claim("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c")
claim("iss", "https://example.com/issuer")
claim("iat", 1516239022)
claim("exp", 1735689661)
sdObjClaim("address") {
sdClaim("street_address", "Schulstr. 12")
sdClaim("locality", "Schulpforta")
sdClaim("region", "Sachsen-Anhalt")
sdClaim("country", "DE")
}
}
val issuer = issuer(signer = RSASSASigner(issuerKeyPair), signAlgorithm = JWSAlgorithm.RS256)
issuer.issue(sdJwtSpec).getOrThrow()
}
val addressPath = ClaimPath.claim("address")
val claimsToInclude = setOf(addressPath.claim("region"), addressPath.claim("country"))
issuedSdJwt.present(claimsToInclude)!!
}
}
You can get the full code here.
In the above example, the Holder
has decided to disclose the claims region
and country
of the selectively
disclosed claim address
.
The resulting presentation will contain 3 disclosures:
- 1 disclosure for the selectively disclosed claim
address
- 1 disclosure for the selectively disclosed claim
region
- 1 disclosure for the selectively disclosed claim
country
This is because to disclose either the claim region
or the claim country
, the claim address
must be
disclosed as well.
Verifier should know the public key of the Issuer and the algorithm used by the Issuer
to sign the SD-JWT. Also, if verification includes Key Binding, the Verifier must also
know how the public key of the Holder was included in the SD-JWT and which algorithm
the Holder used to sign the Key Binding JWT
val verifiedPresentationSdJwt: SdJwt<SignedJWT> = runBlocking {
with(NimbusSdJwtOps) {
val issuerKeyPair = loadRsaKey("/examplesIssuerKey.json")
val jwtSignatureVerifier = RSASSAVerifier(issuerKeyPair).asJwtVerifier()
val unverifiedPresentationSdJwt = loadSdJwt("/examplePresentationSdJwt.txt")
verify(
jwtSignatureVerifier,
unverifiedPresentationSdJwt,
).getOrThrow()
}
}
You can get the full code here.
Library provides various variants of the above method that:
- Preserve the KB-JWT, if present, to the successful outcome of a verification
- Accept the unverified SD-JWT serialized in JWS JSON
Please check KeyBindingTest for a more advanced presentation scenario which includes key binding
Given an SdJwt
, either issuance or presentation, the original claims used to produce the SD-JWT can be
recreated. This includes the claims that are always disclosed (included in the JWT part of the SD-JWT) having
the digests replaced by selectively disclosable claims found in disclosures.
val claims: JsonObject = runBlocking {
val issuerKeyPair: RSAKey = loadRsaKey("/examplesIssuerKey.json")
val sdJwt: SdJwt<SignedJWT> = run {
val spec = sdJwt {
claim("sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c")
claim("iss", "https://example.com/issuer")
claim("iat", 1516239022)
claim("exp", 1735689661)
objClaim("address") {
sdClaim("street_address", "Schulstr. 12")
sdClaim("locality", "Schulpforta")
sdClaim("region", "Sachsen-Anhalt")
sdClaim("country", "DE")
}
}
val issuer = NimbusSdJwtOps.issuer(signer = RSASSASigner(issuerKeyPair), signAlgorithm = JWSAlgorithm.RS256)
issuer.issue(spec).getOrThrow()
}
with(NimbusSdJwtOps) {
sdJwt.recreateClaims(visitor = null)
}
}
You can get the full code here.
The claims contents would be
{
"sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c",
"address": {
"street_address": "Schulstr. 12",
"locality": "Schulpforta",
"region": "Sachsen-Anhalt",
"country": "DE"
},
"iss": "https://example.com/issuer",
"exp": 1735689661,
"iat": 1516239022
}
By default, the library doesn't add decoy digests to the issued SD-JWT. If an issuer wants to use digests, it can do so using the DSL.
DSL functions that mark a container composed of potentially selectively disclosable
elements, such as sdJwt{}
, plain{}
e.t,c, accept
an optional parameter named minimumDigests: Int? = null
.
The issuer can use this parameter to set the minimum number of digests
for the immediate level of this container. Library will make sure that
the underlying digests array will have at minimum a length equal to digestNumberHint
.
Initially, during issuance, the digests array will contain disclosure digests and if needed, additional decoy digests to reach the hint provided. If the array contains more disclosure digests than the hint, no decoys will be added.
val sdJwtWithMinimumDigests = sdJwt(minimumDigests = 5) {
// This 5 guarantees that at least 5 digests will be found
// to the digest array, regardless of the content of the SD-JWT
objClaim("address", minimumDigests = 10) {
// This affects the nested array of the digests that will
// have at list 10 digests.
}
sdObjClaim("address1", minimumDigests = 8) {
// This will affect the digests array that will be found
// in the disclosure of this recursively disclosable item
// the whole object will be embedded in its parent
// as a single digest
}
arrClaim("evidence", minimumDigests = 2) {
// Array will have at least 2 digests
// regardless of its elements
}
sdArrClaim("evidence1", minimumDigests = 2) {
// Array will have at least 2 digests
// regardless of its elements
// the whole array will be embedded in its parent
// as a single digest
}
}
You can get the full code here.
Tip
In addition to the DSL defined hints, the issuer may set a global hint to the SdJwtFactory
.
This will be used as a fallback limit for every container of selectively disclosable elements
that don't explicitly provide a limit.
All examples assume that we have the following claim set
{
"sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c",
"address": {
"street_address": "Schulstr. 12",
"locality": "Schulpforta",
"region": "Sachsen-Anhalt",
"country": "DE"
}
}
- Example 1: Flat SD-JWT
- Example 2: Structured SD-JWT
- Example 3: SD-JWT with Recursive Disclosures
- Appendix 1 - Example 2: Handling Structured Claims
- Appendix 2 - Example 3: Complex Structured SD-JWT
- Appendix 3 - Example 4a: SD-JWT-based Verifiable Credentials (SD-JWT VC)
- Appendix 4 - Example 4b: W3C Verifiable Credentials Data Model v2.0
The library support verifying
SD-JWT-based Verifiable Credentials.
More specifically, Issuer-signed JWT Verification Key Validation support is provided by
SdJwtVcVerifier.
Please check KeyBindingTest for code examples of
verifying an SD-JWT VC and an SD-JWT+KB VC (including verification of the Key Binding JWT).
Example:
val sdJwtVcVerification = runBlocking {
val issuer = URL("https://issuer.example.com")
val key = ECKeyGenerator(Curve.P_521).generate()
val certificate = run {
val clock = Clock.systemDefaultZone()
val issuedAt = clock.instant()
val expiresAt = issuedAt + 365.days.toJavaDuration()
val subject = X500Principal("CN=${issuer.host}")
val signer = JcaContentSignerBuilder("SHA256withECDSA").build(key.toECPrivateKey())
val holder = JcaX509v3CertificateBuilder(
subject,
BigInteger.ONE,
Date.from(issuedAt),
Date.from(expiresAt),
subject,
key.toECPublicKey(),
).addExtension(
Extension.subjectAlternativeName,
true,
GeneralNames.getInstance(DERSequence(GeneralName(GeneralName.dNSName, issuer.host))),
).build(signer)
X509CertUtils.parse(holder.encoded)
}
with(NimbusSdJwtOps) {
val sdJwt = run {
val spec = sdJwt {
claim(RFC7519.ISSUER, issuer.toExternalForm())
claim(SdJwtVcSpec.VCT, "urn:credential:sample")
}
val signer = issuer(signer = ECDSASigner(key), signAlgorithm = JWSAlgorithm.ES512) {
type(JOSEObjectType("vc+sd-jwt"))
x509CertChain(listOf(Base64.encode(certificate.encoded)))
}
signer.issue(spec).getOrThrow().serialize()
}
val verifier = SdJwtVcVerifier.usingX5c { chain -> chain.firstOrNull() == certificate }
verifier.verify(sdJwt)
}
}
You can get the full code here.
Note
Support for OctetKeyPair required the optional dependency com.google.crypto.tink:tink.
We welcome contributions to this project. To ensure that the process is smooth for everyone involved, follow the guidelines found in CONTRIBUTING.md.
Copyright (c) 2023 European Commission
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.