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 all 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 @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ 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].
* 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)
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@ 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.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
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.encodeToString
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlin.random.Random
import kotlinx.serialization.json.jsonObject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours

Expand Down Expand Up @@ -166,44 +167,39 @@ 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 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)
}

/**
* 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
@@ -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<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 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 @@ -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,64 +1,83 @@
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
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))
})
}

/**
* 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 pairs = 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 ->
pair.key?.let { put(it, pair.value) }
}
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) }
}
}
}

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())
}
}
})
}
}
}

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())
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,18 +73,3 @@ data class 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())
}
Loading
Loading