From 1e251872e3b51de7be7a9af61f273426f38669d7 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Sat, 26 Oct 2024 17:38:36 +0200 Subject: [PATCH 1/5] SD-JWT: Add option to create nested disclosable claims --- .../wallet/lib/agent/CredentialToBeIssued.kt | 1 + .../asitplus/wallet/lib/agent/IssuerAgent.kt | 62 ++++++--- .../agent/VerifiablePresentationFactory.kt | 23 ++-- .../lib/data/CredentialToJsonConverter.kt | 12 +- .../wallet/lib/agent/AgentComplexSdJwtTest.kt | 118 ++++++++++++++++++ 5 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt index 599863cb..d7971ed7 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt @@ -29,4 +29,5 @@ sealed class CredentialToBeIssued { ) : CredentialToBeIssued() } +// TODO Add option to NOT make it selective disclosable? data class ClaimToBeIssued(val name: String, val value: Any) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index fdc3be10..291ce440 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -20,13 +20,13 @@ import at.asitplus.wallet.lib.iso.* import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService -import at.asitplus.wallet.lib.jws.SdJwtSigned import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -166,21 +166,30 @@ class IssuerAgent( ) ?: throw IllegalArgumentException("No statusListIndex from issuerCredentialStore") val credentialStatus = CredentialStatus(getRevocationListUrlFor(timePeriod), statusListIndex) - val (disclosures, disclosureDigests) = credential.toDisclosuresAndDigests() + val (sdJwt, disclosures) = credential.claims.toJsonObject() val cnf = ConfirmationClaim(jsonWebKey = credential.subjectPublicKey.toJsonWebKey()) - val jwsPayload = VerifiableCredentialSdJwt( + val vcSdJwt = VerifiableCredentialSdJwt( subject = subjectId, notBefore = issuanceDate, issuer = keyMaterial.identifier, expiration = expirationDate, issuedAt = issuanceDate, jwtId = vcId, - disclosureDigests = disclosureDigests, verifiableCredentialType = credential.scheme.sdJwtType ?: credential.scheme.schemaUri, selectiveDisclosureAlgorithm = "sha-256", cnfElement = vckJsonSerializer.encodeToJsonElement(cnf), credentialStatus = credentialStatus, - ).serialize().encodeToByteArray() + ) + val vcSdJwtObject = vckJsonSerializer.encodeToJsonElement(vcSdJwt).jsonObject + val entireObject = buildJsonObject { + vcSdJwtObject.forEach { + put(it.key, it.value) + } + sdJwt.forEach { + put(it.key, it.value) + } + } + val jwsPayload = vckJsonSerializer.encodeToString(entireObject).encodeToByteArray() val jws = jwsService.createSignedJwt(JwsContentTypeConstants.SD_JWT, jwsPayload).getOrElse { Napier.w("Could not wrap credential in SD-JWT", it) throw RuntimeException("Signing failed", it) @@ -189,20 +198,35 @@ class IssuerAgent( return Issuer.IssuedCredential.VcSdJwt(vcInSdJwt, credential.scheme) } - data class DisclosuresAndDigests( - val disclosures: Collection, - val digests: Collection, - ) + private fun Collection.toJsonObject(): Pair> = + mutableListOf().let { disclosures -> + buildJsonObject { + with(partition { it.value is Collection<*> && it.value.first() is ClaimToBeIssued }) { + val objectClaimDigests = first.map { claim -> + claim.value as Collection<*> + (claim.value.filterIsInstance()).toJsonObject().let { + disclosures.addAll(it.second) + put(claim.name, it.first) + claim.toSdItem(it.first).toDisclosure() + .also { disclosures.add(it) } + .hashDisclosure() + } + } + val singleClaimsDigests = second.map { claim -> + claim.toSdItem().toDisclosure() + .also { disclosures.add(it) } + .hashDisclosure() + } + putJsonArray("_sd") { addAll(objectClaimDigests + singleClaimsDigests) } + } + } to disclosures + } - private fun CredentialToBeIssued.VcSd.toDisclosuresAndDigests(): DisclosuresAndDigests { - val disclosures = claims - .map { SelectiveDisclosureItem(Random.nextBytes(32), it.name, it.value) } - .map { it.toDisclosure() } - // may also include decoy digests - val disclosureDigests = disclosures - .map { it.hashDisclosure() } - return DisclosuresAndDigests(disclosures, disclosureDigests) - } + private fun ClaimToBeIssued.toSdItem(claimValue: JsonObject) = + SelectiveDisclosureItem(Random.nextBytes(32), name, claimValue) + + private fun ClaimToBeIssued.toSdItem() = + SelectiveDisclosureItem(Random.nextBytes(32), name, value) /** * Wraps the revocation information from [issuerCredentialStore] into a VC, diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt index d50e8c1a..67f20d1b 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt @@ -128,20 +128,15 @@ class VerifiablePresentationFactory( validSdJwtCredential: SubjectCredentialStore.StoreEntry.SdJwt, requestedClaims: Collection, ): Holder.CreatePresentationResult.SdJwt { - val filteredDisclosures = requestedClaims.mapNotNull { claimPath -> - // TODO: unsure how to deal with attributes with a depth of more than 1 (if they even should be supported) - // revealing the whole attribute for now, which is as fine grained as SdJwt can do anyway - claimPath.segments.firstOrNull()?.let { - when (it) { - is NormalizedJsonPathSegment.NameSegment -> it.memberName - is NormalizedJsonPathSegment.IndexSegment -> null // can't disclose index - } - } - }.let { requestedRootAttributes -> - validSdJwtCredential.disclosures.filter { - it.discloseItem(requestedRootAttributes) - }.keys - } + // TODO That's not entirely correct, as we would need to verify the nested digests too! + // e.g. "address.region" -> "address" needs to contain hash of "region" in the "_sd" array in claimValue + val filteredDisclosures = requestedClaims + .flatMap { it.segments } + .filterIsInstance() + .mapNotNull { claim -> + validSdJwtCredential.disclosures.entries.firstOrNull { it.value?.claimName == claim.memberName }?.key + }.toSet() + val issuerJwtPlusDisclosures = SdJwtSigned.sdHashInput(validSdJwtCredential, filteredDisclosures) val keyBinding = createKeyBindingJws(audienceId, challenge, issuerJwtPlusDisclosures) val jwsFromIssuer = JwsSigned.deserialize(validSdJwtCredential.vcSerialized.substringBefore("~")).getOrElse { diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index 29cd77e9..4c0477ae 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -1,7 +1,9 @@ package at.asitplus.wallet.lib.data import at.asitplus.signum.indispensable.io.Base64Strict +import at.asitplus.wallet.lib.agent.SdJwtValidator import at.asitplus.wallet.lib.agent.SubjectCredentialStore +import at.asitplus.wallet.lib.jws.SdJwtSigned import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.LocalDate import kotlinx.serialization.json.* @@ -25,12 +27,18 @@ object CredentialToJsonConverter { } is SubjectCredentialStore.StoreEntry.SdJwt -> { - val pairs = credential.disclosures.map { entry -> + val reconstructed = SdJwtSigned.parse(credential.vcSerialized) + ?.let { SdJwtValidator(it).reconstructedJsonObject } + val simpleDisclosureMap = credential.disclosures.map { entry -> entry.value?.let { it.claimName to it.claimValue } }.filterNotNull().toMap() + + buildJsonObject { put("vct", JsonPrimitive(credential.scheme.sdJwtType ?: credential.scheme.vcType)) - pairs.forEach { pair -> + reconstructed?.forEach { + put(it.key, it.value) + } ?: simpleDisclosureMap.forEach { pair -> pair.key?.let { put(it, pair.value) } } } diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt new file mode 100644 index 00000000..89897a7a --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt @@ -0,0 +1,118 @@ +package at.asitplus.wallet.lib.agent + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.dif.Constraint +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.DifInputDescriptor +import at.asitplus.dif.PresentationDefinition +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.wallet.lib.data.ConstantIndex +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.data.ConstantIndex.CredentialRepresentation.SD_JWT +import com.benasher44.uuid.uuid4 +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.datetime.Clock +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Duration.Companion.minutes + +class AgentComplexSdJwtTest : FreeSpec({ + + lateinit var issuer: Issuer + lateinit var holder: Holder + lateinit var verifier: Verifier + lateinit var issuerCredentialStore: IssuerCredentialStore + lateinit var holderCredentialStore: SubjectCredentialStore + lateinit var holderKeyMaterial: KeyMaterial + lateinit var challenge: String + + beforeEach { + issuerCredentialStore = InMemoryIssuerCredentialStore() + holderCredentialStore = InMemorySubjectCredentialStore() + issuer = IssuerAgent(EphemeralKeyWithoutCert(), issuerCredentialStore) + holderKeyMaterial = EphemeralKeyWithSelfSignedCert() + holder = HolderAgent(holderKeyMaterial, holderCredentialStore) + verifier = VerifierAgent() + challenge = uuid4().toString() + } + + "simple walk-through success" { + holder.storeCredential( + issuer.issueCredential( + getCredential(holderKeyMaterial.publicKey, AtomicAttribute2023, SD_JWT).getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ) + + val presentationParameters = holder.createPresentation( + challenge = challenge, + audienceId = verifier.keyMaterial.identifier, + presentationDefinition = buildPresentationDefinition( + "$['$CLAIM_GIVEN_NAME']", + "$['$CLAIM_FAMILY_NAME']", + "$['address']['region']", + "$.address.country", + ) + ).getOrThrow() + + val vp = presentationParameters.presentationResults.firstOrNull() + .shouldBeInstanceOf() + + val verified = verifier.verifyPresentation(vp.sdJwt, challenge) + .shouldBeInstanceOf() + + verified.reconstructedJsonObject[CLAIM_GIVEN_NAME] + ?.jsonPrimitive?.content shouldBe "Susanne" + verified.reconstructedJsonObject[CLAIM_FAMILY_NAME] + ?.jsonPrimitive?.content shouldBe "Meier" + verified.reconstructedJsonObject["address"]?.jsonObject?.get("region") + ?.jsonPrimitive?.content shouldBe "Vienna" + verified.reconstructedJsonObject["address"]?.jsonObject?.get("country") + ?.jsonPrimitive?.content shouldBe "AT" + verified.isRevoked shouldBe false + } + +}) + +private fun getCredential( + subjectPublicKey: CryptoPublicKey, + credentialScheme: ConstantIndex.CredentialScheme, + representation: ConstantIndex.CredentialRepresentation, +): KmmResult = catching { + val claims = listOf( + ClaimToBeIssued(CLAIM_GIVEN_NAME, "Susanne"), + ClaimToBeIssued(CLAIM_FAMILY_NAME, "Meier"), + ClaimToBeIssued( + "address", listOf( + ClaimToBeIssued("region", "Vienna"), + ClaimToBeIssued("country", "AT") + ) + ) + ) + when (representation) { + ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialToBeIssued.VcSd( + claims = claims, + expiration = Clock.System.now() + 1.minutes, + scheme = credentialScheme, + subjectPublicKey = subjectPublicKey, + ) + + else -> throw IllegalArgumentException(representation.toString()) + } +} + +private fun buildPresentationDefinition(vararg attributeName: String) = PresentationDefinition( + id = uuid4().toString(), + inputDescriptors = listOf( + DifInputDescriptor( + id = uuid4().toString(), + constraints = Constraint( + fields = attributeName.map { ConstraintField(path = listOf(it)) } + ) + ) + ) +) From ab24d1e634b55e96f9577f2eead1b3d9e392dcab Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Mon, 28 Oct 2024 21:09:20 +0100 Subject: [PATCH 2/5] SD-JWT: Add option to not make a claim disclosable when issuing --- CHANGELOG.md | 1 + .../wallet/lib/agent/CredentialToBeIssued.kt | 8 +- .../asitplus/wallet/lib/agent/IssuerAgent.kt | 57 ++++-- .../agent/VerifiablePresentationFactory.kt | 2 - .../lib/data/CredentialToJsonConverter.kt | 7 +- .../lib/data/SelectiveDisclosureItem.kt | 1 + .../wallet/lib/agent/AgentComplexSdJwtTest.kt | 184 +++++++++++++----- 7 files changed, 193 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5815b701..79593850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Release 5.1.0: - Correctly implement confirmation claim in `VerifiableCredentialSdJwt`, migrating from `JsonWebKey` to `ConfirmationClaim` - Change type of `claimValue` in `SelectiveDisclosureItem` from `JsonPrimitive` to `JsonElement` to be able to process nested disclosures - Implement deserialization of complex objects, including array claims + - Add option to issue nested disclosures, by using `ClaimToBeIssued` recursively, see documentation there Release 5.0.1: - Update JsonPath4K to 2.4.0 diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt index d7971ed7..4f902bf0 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt @@ -29,5 +29,9 @@ sealed class CredentialToBeIssued { ) : CredentialToBeIssued() } -// TODO Add option to NOT make it selective disclosable? -data class ClaimToBeIssued(val name: String, val value: Any) \ No newline at end of file +/** + * 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]. + */ +data class ClaimToBeIssued(val name: String, val value: Any, val selectivelyDisclosable: Boolean = true) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index 291ce440..6bc67c67 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -5,6 +5,7 @@ import at.asitplus.catching import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.io.Base64Strict +import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.io.BitSet import at.asitplus.signum.indispensable.josef.ConfirmationClaim import at.asitplus.signum.indispensable.josef.toJsonWebKey @@ -25,6 +26,7 @@ import io.github.aakira.napier.Napier import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* import kotlin.random.Random @@ -166,7 +168,7 @@ class IssuerAgent( ) ?: throw IllegalArgumentException("No statusListIndex from issuerCredentialStore") val credentialStatus = CredentialStatus(getRevocationListUrlFor(timePeriod), statusListIndex) - val (sdJwt, disclosures) = credential.claims.toJsonObject() + val (sdJwt, disclosures) = credential.claims.toSdJsonObject() val cnf = ConfirmationClaim(jsonWebKey = credential.subjectPublicKey.toJsonWebKey()) val vcSdJwt = VerifiableCredentialSdJwt( subject = subjectId, @@ -195,33 +197,64 @@ class IssuerAgent( throw RuntimeException("Signing failed", it) } val vcInSdJwt = (listOf(jws.serialize()) + disclosures).joinToString("~", postfix = "~") + Napier.i("issueVcSd: $vcInSdJwt") return Issuer.IssuedCredential.VcSdJwt(vcInSdJwt, credential.scheme) } - private fun Collection.toJsonObject(): Pair> = + private fun Collection.toSdJsonObject(): Pair> = mutableListOf().let { disclosures -> buildJsonObject { with(partition { it.value is Collection<*> && it.value.first() is ClaimToBeIssued }) { - val objectClaimDigests = first.map { claim -> + val objectClaimDigests = first.mapNotNull { claim -> claim.value as Collection<*> - (claim.value.filterIsInstance()).toJsonObject().let { - disclosures.addAll(it.second) - put(claim.name, it.first) - claim.toSdItem(it.first).toDisclosure() + (claim.value.filterIsInstance()).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 -> + if (claim.selectivelyDisclosable) { + claim.toSdItem().toDisclosure() .also { disclosures.add(it) } .hashDisclosure() + } else { + put(claim.name, claim.value.toJsonElement()) + null } } - val singleClaimsDigests = second.map { claim -> - claim.toSdItem().toDisclosure() - .also { disclosures.add(it) } - .hashDisclosure() + (objectClaimDigests + singleClaimsDigests).let { digests -> + if (digests.isNotEmpty()) + putJsonArray("_sd") { addAll(digests) } } - putJsonArray("_sd") { addAll(objectClaimDigests + singleClaimsDigests) } } } to disclosures } + // TODO Merge with function in [CredentialToJsonConverter] or [SelectiveDisclosureItem]? + private fun Any.toJsonElement(): JsonElement = when (this) { + is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is String -> JsonPrimitive(this) + is ByteArray -> JsonPrimitive(encodeToString(Base64UrlStrict)) + is LocalDate -> JsonPrimitive(this.toString()) + is UByte -> JsonPrimitive(this) + is UShort -> JsonPrimitive(this) + is UInt -> JsonPrimitive(this) + is ULong -> JsonPrimitive(this) + is Collection<*> -> JsonArray(mapNotNull { it?.toJsonElement() }.toList()) + is JsonElement -> this + else -> JsonPrimitive(toString()) + } + private fun ClaimToBeIssued.toSdItem(claimValue: JsonObject) = SelectiveDisclosureItem(Random.nextBytes(32), name, claimValue) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt index 67f20d1b..6363ac1d 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt @@ -128,8 +128,6 @@ class VerifiablePresentationFactory( validSdJwtCredential: SubjectCredentialStore.StoreEntry.SdJwt, requestedClaims: Collection, ): Holder.CreatePresentationResult.SdJwt { - // TODO That's not entirely correct, as we would need to verify the nested digests too! - // e.g. "address.region" -> "address" needs to contain hash of "region" in the "_sd" array in claimValue val filteredDisclosures = requestedClaims .flatMap { it.segments } .filterIsInstance() diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index 4c0477ae..716073a2 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -27,8 +27,9 @@ object CredentialToJsonConverter { } is SubjectCredentialStore.StoreEntry.SdJwt -> { - val reconstructed = SdJwtSigned.parse(credential.vcSerialized) - ?.let { SdJwtValidator(it).reconstructedJsonObject } + val sdJwtSigned = SdJwtSigned.parse(credential.vcSerialized) + val payloadVc = sdJwtSigned?.getPayloadAsJsonObject()?.getOrNull() + val reconstructed = sdJwtSigned?.let { SdJwtValidator(it).reconstructedJsonObject } val simpleDisclosureMap = credential.disclosures.map { entry -> entry.value?.let { it.claimName to it.claimValue } }.filterNotNull().toMap() @@ -36,6 +37,7 @@ object CredentialToJsonConverter { buildJsonObject { put("vct", JsonPrimitive(credential.scheme.sdJwtType ?: credential.scheme.vcType)) + payloadVc?.forEach { put(it.key, it.value) } reconstructed?.forEach { put(it.key, it.value) } ?: simpleDisclosureMap.forEach { pair -> @@ -61,6 +63,7 @@ object CredentialToJsonConverter { } } + // TODO Merge with that one function in [SelectiveDisclosureItem]? private fun Any.toJsonElement(): JsonElement = when (this) { is Boolean -> JsonPrimitive(this) is String -> JsonPrimitive(this) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt index 52c9045d..216c2eb4 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt @@ -76,6 +76,7 @@ data class SelectiveDisclosureItem( } +// TODO Merge with that one function in [CredentialToJsonConverter]? private fun Any.toJsonElement(): JsonElement = when (this) { is Boolean -> JsonPrimitive(this) is Number -> JsonPrimitive(this) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt index 89897a7a..e6db9d29 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentComplexSdJwtTest.kt @@ -1,17 +1,12 @@ package at.asitplus.wallet.lib.agent -import at.asitplus.KmmResult -import at.asitplus.catching import at.asitplus.dif.Constraint import at.asitplus.dif.ConstraintField import at.asitplus.dif.DifInputDescriptor import at.asitplus.dif.PresentationDefinition -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.wallet.lib.data.ConstantIndex 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.data.ConstantIndex.CredentialRepresentation.SD_JWT import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe @@ -21,6 +16,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.time.Duration.Companion.minutes + class AgentComplexSdJwtTest : FreeSpec({ lateinit var issuer: Issuer @@ -41,68 +37,141 @@ class AgentComplexSdJwtTest : FreeSpec({ challenge = uuid4().toString() } - "simple walk-through success" { - holder.storeCredential( - issuer.issueCredential( - getCredential(holderKeyMaterial.publicKey, AtomicAttribute2023, SD_JWT).getOrThrow() - ).getOrThrow().toStoreCredentialInput() + "with flat address" { + listOf( + ClaimToBeIssued( + CLAIM_ADDRESS, listOf( + ClaimToBeIssued(CLAIM_ADDRESS_REGION, "Vienna", selectivelyDisclosable = false), + ClaimToBeIssued(CLAIM_ADDRESS_COUNTRY, "AT", selectivelyDisclosable = false) + ) + ), + ).apply { issueAndStoreCredential(holder, issuer, this, holderKeyMaterial) } + + val presentationDefinition = buildPresentationDefinition( + "$['$CLAIM_ADDRESS']['$CLAIM_ADDRESS_REGION']", + "$.$CLAIM_ADDRESS.$CLAIM_ADDRESS_COUNTRY", ) - val presentationParameters = holder.createPresentation( - challenge = challenge, - audienceId = verifier.keyMaterial.identifier, - presentationDefinition = buildPresentationDefinition( - "$['$CLAIM_GIVEN_NAME']", - "$['$CLAIM_FAMILY_NAME']", - "$['address']['region']", - "$.address.country", - ) - ).getOrThrow() + val vp = createPresentation(holder, challenge, verifier, presentationDefinition) + .shouldBeInstanceOf() + + val verified = verifier.verifyPresentation(vp.sdJwt, challenge) + .shouldBeInstanceOf() + + verified.disclosures.size shouldBe 1 // for address only + + 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" + } + + "with claims in address selectively disclosable, but address not" { + listOf( + ClaimToBeIssued( + CLAIM_ADDRESS, listOf( + ClaimToBeIssued(CLAIM_ADDRESS_REGION, "Vienna"), + ClaimToBeIssued(CLAIM_ADDRESS_COUNTRY, "AT") + ), selectivelyDisclosable = false + ), + ).apply { issueAndStoreCredential(holder, issuer, this, holderKeyMaterial) } - val vp = presentationParameters.presentationResults.firstOrNull() + val presentationDefinition = buildPresentationDefinition( + "$['$CLAIM_ADDRESS']['$CLAIM_ADDRESS_REGION']", + "$.$CLAIM_ADDRESS.$CLAIM_ADDRESS_COUNTRY", + ) + + val vp = createPresentation(holder, challenge, verifier, presentationDefinition) .shouldBeInstanceOf() val verified = verifier.verifyPresentation(vp.sdJwt, challenge) .shouldBeInstanceOf() + verified.disclosures.size shouldBe 2 // for 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" + } + + "with claims in address recursively selectively disclosable" { + listOf( + ClaimToBeIssued( + CLAIM_ADDRESS, + listOf( + ClaimToBeIssued(CLAIM_ADDRESS_REGION, "Vienna"), + ClaimToBeIssued(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, verifier, presentationDefinition) + .shouldBeInstanceOf() + + val verified = verifier.verifyPresentation(vp.sdJwt, challenge) + .shouldBeInstanceOf() + + 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"), + ClaimToBeIssued(CLAIM_FAMILY_NAME, "Meier"), + ClaimToBeIssued(CLAIM_ALWAYS_VISIBLE, "anything", selectivelyDisclosable = false) + ).apply { issueAndStoreCredential(holder, issuer, this, holderKeyMaterial) } + + val presentationDefinition = buildPresentationDefinition( + "$['$CLAIM_GIVEN_NAME']", + "$['$CLAIM_FAMILY_NAME']", + "$.$CLAIM_ALWAYS_VISIBLE" + ) + + val vp = createPresentation(holder, challenge, verifier, presentationDefinition) + .shouldBeInstanceOf() + + val verified = verifier.verifyPresentation(vp.sdJwt, challenge) + .shouldBeInstanceOf() + + verified.disclosures.size shouldBe 2 // claim_given_name, claim_family_name + verified.reconstructedJsonObject[CLAIM_GIVEN_NAME] ?.jsonPrimitive?.content shouldBe "Susanne" verified.reconstructedJsonObject[CLAIM_FAMILY_NAME] ?.jsonPrimitive?.content shouldBe "Meier" - verified.reconstructedJsonObject["address"]?.jsonObject?.get("region") - ?.jsonPrimitive?.content shouldBe "Vienna" - verified.reconstructedJsonObject["address"]?.jsonObject?.get("country") - ?.jsonPrimitive?.content shouldBe "AT" - verified.isRevoked shouldBe false + verified.reconstructedJsonObject[CLAIM_ALWAYS_VISIBLE] + ?.jsonPrimitive?.content shouldBe "anything" } }) -private fun getCredential( - subjectPublicKey: CryptoPublicKey, - credentialScheme: ConstantIndex.CredentialScheme, - representation: ConstantIndex.CredentialRepresentation, -): KmmResult = catching { - val claims = listOf( - ClaimToBeIssued(CLAIM_GIVEN_NAME, "Susanne"), - ClaimToBeIssued(CLAIM_FAMILY_NAME, "Meier"), - ClaimToBeIssued( - "address", listOf( - ClaimToBeIssued("region", "Vienna"), - ClaimToBeIssued("country", "AT") +private suspend fun issueAndStoreCredential( + holder: Holder, + issuer: Issuer, + claims: List, + holderKeyMaterial: KeyMaterial +) { + holder.storeCredential( + issuer.issueCredential( + CredentialToBeIssued.VcSd( + claims = claims, + expiration = Clock.System.now() + 1.minutes, + scheme = AtomicAttribute2023, + subjectPublicKey = holderKeyMaterial.publicKey, ) - ) + ).getOrThrow().toStoreCredentialInput() ) - when (representation) { - ConstantIndex.CredentialRepresentation.SD_JWT -> CredentialToBeIssued.VcSd( - claims = claims, - expiration = Clock.System.now() + 1.minutes, - scheme = credentialScheme, - subjectPublicKey = subjectPublicKey, - ) - - else -> throw IllegalArgumentException(representation.toString()) - } } private fun buildPresentationDefinition(vararg attributeName: String) = PresentationDefinition( @@ -116,3 +185,20 @@ private fun buildPresentationDefinition(vararg attributeName: String) = Presenta ) ) ) + +private suspend fun createPresentation( + holder: Holder, + challenge: String, + verifier: Verifier, + presentationDefinition: PresentationDefinition +) = holder.createPresentation( + challenge = challenge, + audienceId = verifier.keyMaterial.identifier, + presentationDefinition = presentationDefinition +).getOrThrow().presentationResults.firstOrNull() + + +private const val CLAIM_ALWAYS_VISIBLE = "alwaysVisible" +private const val CLAIM_ADDRESS = "address" +private const val CLAIM_ADDRESS_REGION = "region" +private const val CLAIM_ADDRESS_COUNTRY = "country" \ No newline at end of file From 489d3c135ede5f8eb0a876e7812a39df57964879 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 29 Oct 2024 10:05:50 +0100 Subject: [PATCH 3/5] Refactor code --- .../lib/data/CredentialToJsonConverter.kt | 84 +++++++++---------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index 716073a2..c5b6eb6d 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -8,57 +8,55 @@ import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.LocalDate import kotlinx.serialization.json.* +/** + * In OpenID4VP, the claims to be presented are described using a JSONPath, so compiling this to a JsonElement seems + * reasonable. + */ object CredentialToJsonConverter { - // in openid4vp, the claims to be presented are described using a JSONPath, so compiling this to a JsonElement seems reasonable - fun toJsonElement(credential: SubjectCredentialStore.StoreEntry): JsonElement { - return when (credential) { - is SubjectCredentialStore.StoreEntry.Vc -> { - buildJsonObject { - put("type", JsonPrimitive(credential.scheme.vcType)) - vckJsonSerializer.encodeToJsonElement(credential.vc.vc.credentialSubject).jsonObject.entries.forEach { - put(it.key, it.value) - } - // TODO: Remove the rest here when there is a clear specification on how to encode vc credentials - // This may actually depend on the presentation context, so more information may be required - put("vc", buildJsonArray { - add(vckJsonSerializer.encodeToJsonElement(credential.vc.vc.credentialSubject)) - }) - } - } - is SubjectCredentialStore.StoreEntry.SdJwt -> { - val sdJwtSigned = SdJwtSigned.parse(credential.vcSerialized) - val payloadVc = sdJwtSigned?.getPayloadAsJsonObject()?.getOrNull() - val reconstructed = sdJwtSigned?.let { SdJwtValidator(it).reconstructedJsonObject } - val simpleDisclosureMap = credential.disclosures.map { entry -> - entry.value?.let { it.claimName to it.claimValue } - }.filterNotNull().toMap() + /** + * The result is used in [at.asitplus.wallet.lib.data.dif.InputEvaluator.evaluateConstraintFieldMatches] + */ + fun toJsonElement(credential: SubjectCredentialStore.StoreEntry): JsonElement = when (credential) { + is SubjectCredentialStore.StoreEntry.Vc -> buildJsonObject { + put("type", JsonPrimitive(credential.scheme.vcType)) + val vcAsJsonElement = vckJsonSerializer.encodeToJsonElement(credential.vc.vc.credentialSubject) + vcAsJsonElement.jsonObject.entries.forEach { + put(it.key, it.value) + } + // TODO: Remove the rest here when there is a clear specification on how to encode vc credentials + // This may actually depend on the presentation context, so more information may be required + put("vc", buildJsonArray { + add(vcAsJsonElement) + }) + } + is SubjectCredentialStore.StoreEntry.SdJwt -> { + val sdJwtSigned = SdJwtSigned.parse(credential.vcSerialized) + val payloadVc = sdJwtSigned?.getPayloadAsJsonObject()?.getOrNull() + val reconstructed = sdJwtSigned?.let { SdJwtValidator(it).reconstructedJsonObject } + val simpleDisclosureMap = credential.disclosures.map { entry -> + entry.value?.let { it.claimName to it.claimValue } + }.filterNotNull().toMap() - buildJsonObject { - put("vct", JsonPrimitive(credential.scheme.sdJwtType ?: credential.scheme.vcType)) - payloadVc?.forEach { put(it.key, it.value) } - reconstructed?.forEach { - put(it.key, it.value) - } ?: simpleDisclosureMap.forEach { pair -> - pair.key?.let { put(it, pair.value) } - } + buildJsonObject { + put("vct", JsonPrimitive(credential.scheme.sdJwtType ?: credential.scheme.vcType)) + payloadVc?.forEach { put(it.key, it.value) } + reconstructed?.forEach { + put(it.key, it.value) + } ?: simpleDisclosureMap.forEach { pair -> + pair.key?.let { put(it, pair.value) } } } + } - is SubjectCredentialStore.StoreEntry.Iso -> { - buildJsonObject { - credential.issuerSigned.namespaces?.forEach { - put(it.key, buildJsonObject { - it.value.entries.forEach { signedItem -> - put( - signedItem.value.elementIdentifier, - signedItem.value.elementValue.toJsonElement() - ) - } - }) + is SubjectCredentialStore.StoreEntry.Iso -> buildJsonObject { + credential.issuerSigned.namespaces?.forEach { + put(it.key, buildJsonObject { + it.value.entries.map { it.value }.forEach { value -> + put(value.elementIdentifier, value.elementValue.toJsonElement()) } - } + }) } } } From 0e699a63fdb91fbdc36e2b9a39472b5128819e79 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 29 Oct 2024 10:10:59 +0100 Subject: [PATCH 4/5] Use one common method to encode anything into JsonElement --- .../asitplus/wallet/lib/LibraryInitializer.kt | 3 +-- .../asitplus/wallet/lib/agent/IssuerAgent.kt | 19 +-------------- .../lib/data/CredentialToJsonConverter.kt | 24 +++++++++++++------ .../lib/data/SelectiveDisclosureItem.kt | 20 +--------------- 4 files changed, 20 insertions(+), 46 deletions(-) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt index df31321b..d35d3163 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt @@ -101,11 +101,10 @@ object LibraryInitializer { * ``` * when (it) { * is DrivingPrivilege -> vckJsonSerializer.encodeToJsonElement(it) - * is LocalDate -> vckJsonSerializer.encodeToJsonElement(it) - * is UInt -> vckJsonSerializer.encodeToJsonElement(it) * else -> null * } * ``` + * Credential libraries need to implement only for custom types, as platform types are covered by this library. */ typealias JsonValueEncoder = (value: Any) -> JsonElement? diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index 6bc67c67..d7d395b3 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -5,7 +5,6 @@ import at.asitplus.catching import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.io.Base64Strict -import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.io.BitSet import at.asitplus.signum.indispensable.josef.ConfirmationClaim import at.asitplus.signum.indispensable.josef.toJsonWebKey @@ -15,6 +14,7 @@ import at.asitplus.wallet.lib.ZlibService import at.asitplus.wallet.lib.cbor.CoseService import at.asitplus.wallet.lib.cbor.DefaultCoseService import at.asitplus.wallet.lib.data.* +import at.asitplus.wallet.lib.data.CredentialToJsonConverter.toJsonElement import at.asitplus.wallet.lib.data.SelectiveDisclosureItem.Companion.hashDisclosure import at.asitplus.wallet.lib.data.VcDataModelConstants.REVOCATION_LIST_MIN_SIZE import at.asitplus.wallet.lib.iso.* @@ -26,7 +26,6 @@ import io.github.aakira.napier.Napier import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlinx.datetime.LocalDate import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* import kotlin.random.Random @@ -239,22 +238,6 @@ class IssuerAgent( } to disclosures } - // TODO Merge with function in [CredentialToJsonConverter] or [SelectiveDisclosureItem]? - private fun Any.toJsonElement(): JsonElement = when (this) { - is Boolean -> JsonPrimitive(this) - is Number -> JsonPrimitive(this) - is String -> JsonPrimitive(this) - is ByteArray -> JsonPrimitive(encodeToString(Base64UrlStrict)) - is LocalDate -> JsonPrimitive(this.toString()) - is UByte -> JsonPrimitive(this) - is UShort -> JsonPrimitive(this) - is UInt -> JsonPrimitive(this) - is ULong -> JsonPrimitive(this) - is Collection<*> -> JsonArray(mapNotNull { it?.toJsonElement() }.toList()) - is JsonElement -> this - else -> JsonPrimitive(toString()) - } - private fun ClaimToBeIssued.toSdItem(claimValue: JsonObject) = SelectiveDisclosureItem(Random.nextBytes(32), name, claimValue) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index c5b6eb6d..f5f4ddee 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -1,6 +1,6 @@ package at.asitplus.wallet.lib.data -import at.asitplus.signum.indispensable.io.Base64Strict +import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.wallet.lib.agent.SdJwtValidator import at.asitplus.wallet.lib.agent.SubjectCredentialStore import at.asitplus.wallet.lib.jws.SdJwtSigned @@ -61,13 +61,23 @@ object CredentialToJsonConverter { } } - // TODO Merge with that one function in [SelectiveDisclosureItem]? - private fun Any.toJsonElement(): JsonElement = when (this) { + /** + * Converts any value to a [JsonElement], to be used when serializing values into JSON structures. + */ + fun Any.toJsonElement(): JsonElement = when (this) { is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) is String -> JsonPrimitive(this) - is ByteArray -> JsonPrimitive(encodeToString(Base64Strict)) + is ByteArray -> JsonPrimitive(encodeToString(Base64UrlStrict)) is LocalDate -> JsonPrimitive(this.toString()) - is Array<*> -> buildJsonArray { filterNotNull().forEach { add(it.toJsonElement()) } } - else -> JsonCredentialSerializer.encode(this) ?: JsonNull + is UByte -> JsonPrimitive(this) + is UShort -> JsonPrimitive(this) + is UInt -> JsonPrimitive(this) + is ULong -> JsonPrimitive(this) + is Collection<*> -> JsonArray(mapNotNull { it?.toJsonElement() }.toList()) + is Array<*> -> JsonArray(mapNotNull { it?.toJsonElement() }.toList()) + is JsonElement -> this + else -> JsonCredentialSerializer.encode(this) ?: JsonPrimitive(toString()) } -} \ No newline at end of file +} + diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt index 216c2eb4..98eecdee 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/SelectiveDisclosureItem.kt @@ -2,16 +2,14 @@ package at.asitplus.wallet.lib.data import at.asitplus.KmmResult.Companion.wrap import at.asitplus.signum.indispensable.io.Base64UrlStrict +import at.asitplus.wallet.lib.data.CredentialToJsonConverter.toJsonElement import at.asitplus.wallet.lib.iso.sha256 import at.asitplus.wallet.lib.jws.SelectiveDisclosureItemSerializer import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive /** * Selective Disclosure item in SD-JWT format @@ -75,19 +73,3 @@ data class SelectiveDisclosureItem( } } - -// TODO Merge with that one function in [CredentialToJsonConverter]? -private fun Any.toJsonElement(): JsonElement = when (this) { - is Boolean -> JsonPrimitive(this) - is Number -> JsonPrimitive(this) - is String -> JsonPrimitive(this) - is ByteArray -> JsonPrimitive(encodeToString(Base64UrlStrict)) - is LocalDate -> JsonPrimitive(this.toString()) - is UByte -> JsonPrimitive(this) - is UShort -> JsonPrimitive(this) - is UInt -> JsonPrimitive(this) - is ULong -> JsonPrimitive(this) - is Collection<*> -> JsonArray(mapNotNull { it?.toJsonElement() }.toList()) - is JsonElement -> this - else -> JsonPrimitive(toString()) -} \ No newline at end of file From e234fd03f51f95cd27c47360f136edbb52a81a86 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 29 Oct 2024 10:40:20 +0100 Subject: [PATCH 5/5] SD-JWT: Extract creating SD JWT into separate file --- .../wallet/lib/agent/CredentialToBeIssued.kt | 1 + .../asitplus/wallet/lib/agent/IssuerAgent.kt | 52 ++------------ .../asitplus/wallet/lib/agent/SdJwtCreator.kt | 67 +++++++++++++++++++ 3 files changed, 72 insertions(+), 48 deletions(-) create mode 100644 vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SdJwtCreator.kt diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt index 4f902bf0..030b36c4 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/CredentialToBeIssued.kt @@ -33,5 +33,6 @@ 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]. + * 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) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index d7d395b3..9aec2c91 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -11,11 +11,10 @@ import at.asitplus.signum.indispensable.josef.toJsonWebKey import at.asitplus.wallet.lib.DataSourceProblem import at.asitplus.wallet.lib.DefaultZlibService import at.asitplus.wallet.lib.ZlibService +import at.asitplus.wallet.lib.agent.SdJwtCreator.toSdJsonObject import at.asitplus.wallet.lib.cbor.CoseService import at.asitplus.wallet.lib.cbor.DefaultCoseService import at.asitplus.wallet.lib.data.* -import at.asitplus.wallet.lib.data.CredentialToJsonConverter.toJsonElement -import at.asitplus.wallet.lib.data.SelectiveDisclosureItem.Companion.hashDisclosure import at.asitplus.wallet.lib.data.VcDataModelConstants.REVOCATION_LIST_MIN_SIZE import at.asitplus.wallet.lib.iso.* import at.asitplus.wallet.lib.jws.DefaultJwsService @@ -27,8 +26,9 @@ import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.* -import kotlin.random.Random +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -200,50 +200,6 @@ class IssuerAgent( return Issuer.IssuedCredential.VcSdJwt(vcInSdJwt, credential.scheme) } - private fun Collection.toSdJsonObject(): Pair> = - mutableListOf().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()).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 -> - if (claim.selectivelyDisclosable) { - claim.toSdItem().toDisclosure() - .also { disclosures.add(it) } - .hashDisclosure() - } else { - put(claim.name, claim.value.toJsonElement()) - null - } - } - (objectClaimDigests + singleClaimsDigests).let { digests -> - if (digests.isNotEmpty()) - putJsonArray("_sd") { addAll(digests) } - } - } - } to disclosures - } - - private fun ClaimToBeIssued.toSdItem(claimValue: JsonObject) = - SelectiveDisclosureItem(Random.nextBytes(32), name, claimValue) - - private fun ClaimToBeIssued.toSdItem() = - SelectiveDisclosureItem(Random.nextBytes(32), name, value) - /** * Wraps the revocation information from [issuerCredentialStore] into a VC, * returns a JWS representation of that. diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SdJwtCreator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SdJwtCreator.kt new file mode 100644 index 00000000..aa606edb --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SdJwtCreator.kt @@ -0,0 +1,67 @@ +package at.asitplus.wallet.lib.agent + +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 kotlin.random.Random + +/** + * See [Selective Disclosure for JWTs (SD-JWT)](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-13.html#name-simple-structured-sd-jwt) + */ +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]) + * + * @return The encoded JSON object and the disclosure strings + */ + fun Collection.toSdJsonObject(): Pair> = + mutableListOf().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()).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 -> + if (claim.selectivelyDisclosable) { + claim.toSdItem().toDisclosure() + .also { disclosures.add(it) } + .hashDisclosure() + } else { + put(claim.name, claim.value.toJsonElement()) + null + } + } + (objectClaimDigests + singleClaimsDigests).let { digests -> + if (digests.isNotEmpty()) + putJsonArray("_sd") { addAll(digests) } + } + } + } to disclosures + } + + private fun ClaimToBeIssued.toSdItem(claimValue: JsonObject) = + SelectiveDisclosureItem(Random.nextBytes(32), name, claimValue) + + private fun ClaimToBeIssued.toSdItem() = + SelectiveDisclosureItem(Random.nextBytes(32), name, value) + +} \ No newline at end of file