Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SD-JWT: Create complex disclosures #156

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ sealed class CredentialToBeIssued {
) : CredentialToBeIssued()
}

data class ClaimToBeIssued(val name: String, val value: Any)
/**
* 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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,13 +21,14 @@ 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.datetime.LocalDate
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
Expand Down Expand Up @@ -166,44 +168,99 @@ class IssuerAgent(
) ?: throw IllegalArgumentException("No statusListIndex from issuerCredentialStore")

val credentialStatus = CredentialStatus(getRevocationListUrlFor(timePeriod), statusListIndex)
val (disclosures, disclosureDigests) = credential.toDisclosuresAndDigests()
val (sdJwt, disclosures) = credential.claims.toSdJsonObject()
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)
}
val vcInSdJwt = (listOf(jws.serialize()) + disclosures).joinToString("~", postfix = "~")
Napier.i("issueVcSd: $vcInSdJwt")
return Issuer.IssuedCredential.VcSdJwt(vcInSdJwt, credential.scheme)
}

data class DisclosuresAndDigests(
val disclosures: Collection<String>,
val digests: Collection<String>,
)
private 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 ->
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 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)
// 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)

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,13 @@ class VerifiablePresentationFactory(
validSdJwtCredential: SubjectCredentialStore.StoreEntry.SdJwt,
requestedClaims: Collection<NormalizedJsonPath>,
): 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
}
val filteredDisclosures = requestedClaims
.flatMap { it.segments }
.filterIsInstance<NormalizedJsonPathSegment.NameSegment>()
.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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*
Expand All @@ -25,12 +27,20 @@ object CredentialToJsonConverter {
}

is SubjectCredentialStore.StoreEntry.SdJwt -> {
val pairs = credential.disclosures.map { entry ->
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))
pairs.forEach { pair ->
payloadVc?.forEach { put(it.key, it.value) }
reconstructed?.forEach {
put(it.key, it.value)
} ?: simpleDisclosureMap.forEach { pair ->
pair.key?.let { put(it, pair.value) }
}
}
Expand All @@ -53,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading