Skip to content

Commit

Permalink
SD-JWT: Support creating nested structures with dot notation
Browse files Browse the repository at this point in the history
  • Loading branch information
nodh committed Jan 16, 2025
1 parent 3d7b3de commit d60b266
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 41 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Release 5.3.0:
- Move `AuthorizationResponsePreparationState` from `at.asitplus.wallet.lib.oidc.helpers` to `at.asitplus.wallet.lib.openid`
- General cleanup:
- Remove `SchemaIndex`
- SD-JWT:
- Support creating SD-JWT with nested structures by passing `.` in the claim names, e.g. `address.region`, see `SdJwtCreator` and `ClaimToBeIssued`

Release 5.2.2:
- Remote qualified electronic signatures:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ sealed class CredentialToBeIssued {
}

/**
* Represents a claim that shall be issued to the holder,
* i.e. serialized into the appropriate credential format.
* To issue nested structures in SD-JWT, pass a Collection of [ClaimToBeIssued] in [value].
* Represents a claim that shall be issued to the holder, i.e. serialized into the appropriate credential format.
*
* To issue nested structures in SD-JWT, pick one of two options:
* - Pass a collection of [ClaimToBeIssued] in [value].
* - Put dots `.` in [name], e.g. `address.region`
*
* For each claim, one can select if the claim shall be selectively disclosable, or otherwise included plain.
*/
data class ClaimToBeIssued(val name: String, val value: Any, val selectivelyDisclosable: Boolean = true)
147 changes: 112 additions & 35 deletions vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SdJwtCreator.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package at.asitplus.wallet.lib.agent

import at.asitplus.wallet.lib.agent.SdJwtCreator.toSdJsonObject
import at.asitplus.wallet.lib.data.CredentialToJsonConverter.toJsonElement
import at.asitplus.wallet.lib.data.SelectiveDisclosureItem
import at.asitplus.wallet.lib.data.SelectiveDisclosureItem.Companion.hashDisclosure
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.addAll
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.*
import kotlin.random.Random

/**
Expand All @@ -15,53 +13,132 @@ import kotlin.random.Random
object SdJwtCreator {

/**
* Creates a JSON object to contain only digests for the selectively disclosable claims, and the plain values for
* other claims that are not selectively disclosable (see [ClaimToBeIssued.selectivelyDisclosable])
* Creates a JSON object to contain only digests for the selectively disclosable claims
* (in the array with key `_sd`), and the plain values for
* other claims that are not selectively disclosable (see [ClaimToBeIssued.selectivelyDisclosable]).
*
* Supports creating nested structures in two ways:
* - The [ClaimToBeIssued] contains a collection of other [ClaimToBeIssued] in [ClaimToBeIssued.value]
* - The [ClaimToBeIssued.name] contains dots (`.`) to nest structures, e.g. `address.region`
*
* @return The encoded JSON object and the disclosure strings
*/
fun Collection<ClaimToBeIssued>.toSdJsonObject(): Pair<JsonObject, Collection<String>> =
mutableListOf<String>().let { disclosures ->
buildJsonObject {
with(partition { it.value is Collection<*> && it.value.first() is ClaimToBeIssued }) {
val objectClaimDigests = first.mapNotNull { claim ->
claim.value as Collection<*>
(claim.value.filterIsInstance<ClaimToBeIssued>()).toSdJsonObject().let {
if (claim.selectivelyDisclosable) {
disclosures.addAll(it.second)
put(claim.name, it.first)
claim.toSdItem(it.first).toDisclosure()
.also { disclosures.add(it) }
.hashDisclosure()
} else {
disclosures.addAll(it.second)
put(claim.name, it.first)
null
}
}
}
val singleClaimsDigests = second.mapNotNull { claim ->
fun Collection<ClaimToBeIssued>.toSdJsonObject()
: Pair<JsonObject, Collection<String>> = mutableListOf<String>().let { disclosures ->
buildJsonObject {
with(customPartition()) {
val objectClaimDigests: Collection<String> = recursiveClaims.mapNotNull { claim ->
claim.value as Collection<*>
(claim.value.filterIsInstance<ClaimToBeIssued>()).toSdJsonObject().let {
if (claim.selectivelyDisclosable) {
claim.toSdItem().toDisclosure()
disclosures.addAll(it.second)
put(claim.name, it.first)
claim.toSdItem(it.first).toDisclosure()
.also { disclosures.add(it) }
.hashDisclosure()
} else {
put(claim.name, claim.value.toJsonElement())
disclosures.addAll(it.second)
put(claim.name, it.first)
null
}
}
(objectClaimDigests + singleClaimsDigests).let { digests ->
if (digests.isNotEmpty())
putJsonArray("_sd") { addAll(digests) }
}
val dotNotationClaims: Collection<String> = dotNotation.groupByDots().mapNotNull { (key, claims) ->
claims.toSdJsonObject().let {
disclosures.addAll(it.second)
put(key, it.first)
key.toSdItem(it.first).toDisclosure()
.also { disclosures.add(it) }
.hashDisclosure()
}
}
} to disclosures
}
val dotNotationClaimsPlain: Collection<String> =
dotNotationPlain.groupByDots().mapNotNull { (key, claims) ->
claims.toSdJsonObject().let {
disclosures.addAll(it.second)
put(key, it.first)
null
}
}
val singleClaimsDigests: Collection<String> = claimsWithSimpleValue.mapNotNull { claim ->
if (claim.selectivelyDisclosable) {
claim.toSdItem().toDisclosure()
.also { disclosures.add(it) }
.hashDisclosure()
} else {
put(claim.name, claim.value.toJsonElement())
null
}
}
(objectClaimDigests + dotNotationClaims + dotNotationClaimsPlain + singleClaimsDigests).let { digests ->
if (digests.isNotEmpty())
putJsonArray("_sd") { addAll(digests) }
}
}
} to disclosures
}

/**
* Groups by the object name (the part before the first `.`),
* with the list of values containing the original values, but the name stripped,
* i.e. the part before the first `.` removed.
*
* Example:
* ```
* {
* "address.region": "Vienna",
* "address.country": "AT"
* }
* ```
* turns into
* ```
* {
* "address": {
* "region": "Vienna",
* "country": "AT"
* }
* }
*/
private fun Collection<ClaimToBeIssued>.groupByDots(): Map<String, List<ClaimToBeIssued>> = groupBy(
{ it.name.split(".").first() },
{ it.copy(name = it.name.split(".").drop(1).joinToString()) }
).toMap()

/**
* Holds all the claims to be issued split up into four categories, for easy use in [toSdJsonObject]
*/
data class Partitioned(
val recursiveClaims: Collection<ClaimToBeIssued>,
val dotNotation: Collection<ClaimToBeIssued>,
val dotNotationPlain: Collection<ClaimToBeIssued>,
val claimsWithSimpleValue: Collection<ClaimToBeIssued>,
)

/**
* Partitions the claims to be issued into four categories, for easy use in [toSdJsonObject]
*/
private fun Collection<ClaimToBeIssued>.customPartition(): Partitioned {
val isDotNotation: (ClaimToBeIssued) -> Boolean = { it.name.contains('.') }
val isDisclosable: (ClaimToBeIssued) -> Boolean = { it.selectivelyDisclosable }
val hasCollectionValue: (ClaimToBeIssued) -> Boolean =
{ it.value is Collection<*> && it.value.first() is ClaimToBeIssued }
val (collectionClaims, simpleValueClaims) = partition(hasCollectionValue)
val dotNotationClaims = simpleValueClaims.filter(isDotNotation)
return Partitioned(
collectionClaims,
dotNotationClaims.filter(isDisclosable),
dotNotationClaims.filterNot(isDisclosable),
simpleValueClaims.filterNot(isDotNotation)
)
}

private fun String.toSdItem(claimValue: JsonElement) =
SelectiveDisclosureItem(Random.nextBytes(32), this, claimValue)

private fun ClaimToBeIssued.toSdItem(claimValue: JsonObject) =
SelectiveDisclosureItem(Random.nextBytes(32), name, claimValue)

private fun ClaimToBeIssued.toSdItem() =
SelectiveDisclosureItem(Random.nextBytes(32), name, value)

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import at.asitplus.dif.PresentationDefinition
import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023
import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME
import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME
import at.asitplus.wallet.lib.jws.SdJwtSigned
import com.benasher44.uuid.uuid4
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
Expand Down Expand Up @@ -128,6 +127,31 @@ class AgentComplexSdJwtTest : FreeSpec({
?.jsonPrimitive?.content shouldBe "AT"
}

"with claims in address in dot-notation" {
listOf(
ClaimToBeIssued("$CLAIM_ADDRESS.$CLAIM_ADDRESS_REGION", "Vienna"),
ClaimToBeIssued("$CLAIM_ADDRESS.$CLAIM_ADDRESS_COUNTRY", "AT"),
).apply { issueAndStoreCredential(holder, issuer, this, holderKeyMaterial) }

val presentationDefinition = buildPresentationDefinition(
"$.$CLAIM_ADDRESS.$CLAIM_ADDRESS_REGION",
"$.$CLAIM_ADDRESS.$CLAIM_ADDRESS_COUNTRY",
)

val vp = createPresentation(holder, challenge, presentationDefinition, verifierId)
.shouldBeInstanceOf<CreatePresentationResult.SdJwt>()

val verified = verifier.verifyPresentationSdJwt(vp.sdJwt!!, challenge)
.shouldBeInstanceOf<Verifier.VerifyPresentationResult.SuccessSdJwt>()

verified.disclosures.size shouldBe 3 // for address, region, country

verified.reconstructedJsonObject[CLAIM_ADDRESS]?.jsonObject?.get(CLAIM_ADDRESS_REGION)
?.jsonPrimitive?.content shouldBe "Vienna"
verified.reconstructedJsonObject[CLAIM_ADDRESS]?.jsonObject?.get(CLAIM_ADDRESS_COUNTRY)
?.jsonPrimitive?.content shouldBe "AT"
}

"simple walk-through success" {
listOf(
ClaimToBeIssued(CLAIM_GIVEN_NAME, "Susanne"),
Expand Down Expand Up @@ -163,7 +187,7 @@ private suspend fun issueAndStoreCredential(
holder: Holder,
issuer: Issuer,
claims: List<ClaimToBeIssued>,
holderKeyMaterial: KeyMaterial
holderKeyMaterial: KeyMaterial,
) {
holder.storeCredential(
issuer.issueCredential(
Expand Down Expand Up @@ -193,7 +217,7 @@ private suspend fun createPresentation(
holder: Holder,
challenge: String,
presentationDefinition: PresentationDefinition,
verifierId: String
verifierId: String,
) = holder.createPresentation(
request = PresentationRequestParameters(nonce = challenge, audience = verifierId),
presentationDefinition = presentationDefinition
Expand Down

0 comments on commit d60b266

Please sign in to comment.