diff --git a/conventions-vclib/build.gradle.kts b/conventions-vclib/build.gradle.kts index b59384b2..ad1ba368 100644 --- a/conventions-vclib/build.gradle.kts +++ b/conventions-vclib/build.gradle.kts @@ -17,7 +17,10 @@ dependencies { } repositories { - maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot + maven { + url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") + name = "aspConventions" + } //KOTEST snapshot mavenCentral() google() gradlePluginPortal() diff --git a/conventions-vclib/gradle-conventions-plugin b/conventions-vclib/gradle-conventions-plugin index 1c3077af..3f1fe1b9 160000 --- a/conventions-vclib/gradle-conventions-plugin +++ b/conventions-vclib/gradle-conventions-plugin @@ -1 +1 @@ -Subproject commit 1c3077af25b4d19b88bddbe2d90170cdc3aa481c +Subproject commit 3f1fe1b97a8fda7e1877379148755b42b4c8b0f6 diff --git a/eu-pid-credential b/eu-pid-credential index db0fca45..84c6c28a 160000 --- a/eu-pid-credential +++ b/eu-pid-credential @@ -1 +1 @@ -Subproject commit db0fca454416fe3af4f1ea366195c8839481486a +Subproject commit 84c6c28a6d78d76f1438a9a5cc01f32f9d49d599 diff --git a/mobile-driving-licence-credential b/mobile-driving-licence-credential index db053162..b522f79b 160000 --- a/mobile-driving-licence-credential +++ b/mobile-driving-licence-credential @@ -1 +1 @@ -Subproject commit db053162d8936128027dd1f728889812beb9541b +Subproject commit b522f79bb304b829ff40c66c21d35f1bf66e6bdd diff --git a/settings.gradle.kts b/settings.gradle.kts index 61119be9..e2f15234 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,12 +4,14 @@ import java.util.* pluginManagement { includeBuild("conventions-vclib") repositories { - - maven("https://s01.oss.sonatype.org/content/repositories/snapshots") google() gradlePluginPortal() mavenCentral() - maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") + maven { + url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") + name = "aspConventions" + } } } @@ -43,6 +45,7 @@ include(":openid-data-classes") include(":vck") include(":vck-aries") include(":vck-openid") +include(":mobiledrivinglicence") dependencyResolutionManagement { repositories { 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 92985275..c237899e 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 @@ -134,7 +134,7 @@ class IssuerAgent( digestAlgorithm = "SHA-256", valueDigests = mapOf( scheme.isoNamespace!! to ValueDigestList(credential.issuerSignedItems.map { - ValueDigest.fromIssuerSigned(scheme.isoNamespace!!, it) + ValueDigest.fromIssuerSignedItem(it, scheme.isoNamespace!!) }) ), deviceKeyInfo = deviceKeyInfo, @@ -145,14 +145,13 @@ class IssuerAgent( validUntil = expirationDate, ) ) - val issuerSigned = IssuerSigned( + val issuerSigned = IssuerSigned.fromIssuerSignedItems( namespacedItems = mapOf(scheme.isoNamespace!! to credential.issuerSignedItems), issuerAuth = coseService.createSignedCose( payload = mso.serializeForIssuerAuth(), addKeyId = false, addCertificate = true, ).getOrThrow(), - 24 // TODO verify serialization of this ) return Issuer.IssuedCredential.Iso(issuerSigned, scheme) } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt index 21c3ff34..229d2763 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt @@ -2,6 +2,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.cosef.CoseKey +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.equalsCryptographically import at.asitplus.signum.indispensable.io.Base64Strict @@ -23,7 +24,6 @@ import io.github.aakira.napier.Napier import io.matthewnelson.encoding.base16.Base16 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArrayOrNull import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper /** @@ -262,7 +262,7 @@ class Validator( .also { Napier.w("IssuerAuth not verified: $issuerAuth") } } - val mso = issuerSigned.getIssuerAuthPayloadAsMso() + val mso = issuerSigned.getIssuerAuthPayloadAsMso().getOrNull() ?: return Verifier.VerifyPresentationResult.InvalidStructure(docSerialized) .also { Napier.w("MSO is null: ${issuerAuth.payload?.encodeToString(Base16(strict = true))}") } if (mso.docType != doc.docType) { @@ -303,13 +303,16 @@ class Validator( ) } + /** + * Verify that calculated digests equal the corresponding digest values in the MSO. + * + * See ISO/IEC 18013-5:2021, 9.3.1 Inspection procedure for issuer data authentication + */ private fun ByteStringWrapper.verify(mdlItems: ValueDigestList?): Boolean { - val issuerHash = mdlItems?.entries?.firstOrNull { it.key == value.digestId } ?: return false - // TODO analyze usages of tag wrapping + val issuerHash = mdlItems?.entries?.firstOrNull { it.key == value.digestId } + ?: return false val verifierHash = serialized.wrapInCborTag(24).sha256() - if (!verifierHash.encodeToString(Base16(strict = true)) - .contentEquals(issuerHash.value.encodeToString(Base16(strict = true))) - ) { + if (!verifierHash.contentEquals(issuerHash.value)) { Napier.w("Could not verify hash of value for ${value.elementIdentifier}") return false } 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 8e59853e..942c4341 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 @@ -4,6 +4,7 @@ import at.asitplus.KmmResult import at.asitplus.KmmResult.Companion.wrap import at.asitplus.jsonpath.core.NormalizedJsonPath import at.asitplus.jsonpath.core.NormalizedJsonPathSegment +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper import at.asitplus.signum.indispensable.josef.JwsHeader import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.wallet.lib.cbor.CoseService @@ -101,10 +102,14 @@ class VerifiablePresentationFactory( return Holder.CreatePresentationResult.Document( Document( - docType = credential.scheme.isoDocType!!, issuerSigned = IssuerSigned( - namespacedItems = disclosedItems, issuerAuth = credential.issuerSigned.issuerAuth - ), deviceSigned = DeviceSigned( - namespaces = byteArrayOf(), deviceAuth = DeviceAuth( + docType = credential.scheme.isoDocType!!, + issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = disclosedItems, + issuerAuth = credential.issuerSigned.issuerAuth + ), + deviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces(mapOf())), + deviceAuth = DeviceAuth( deviceSignature = deviceSignature ) ) @@ -194,4 +199,4 @@ class VerifiablePresentationFactory( throw PresentationException(it) }.serialize() ) -} \ No newline at end of file +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceAuth.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceAuth.kt new file mode 100644 index 00000000..b658efc6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceAuth.kt @@ -0,0 +1,16 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.CoseSigned +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class DeviceAuth( + @SerialName("deviceSignature") + val deviceSignature: CoseSigned? = null, + @SerialName("deviceMac") + val deviceMac: CoseSigned? = null, // TODO is COSE_Mac0 +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceKeyInfo.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceKeyInfo.kt new file mode 100644 index 00000000..f0453f13 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceKeyInfo.kt @@ -0,0 +1,30 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.cosef.CoseKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) + */ +@Serializable +data class DeviceKeyInfo( + @SerialName("deviceKey") + val deviceKey: CoseKey, + @SerialName("keyAuthorizations") + val keyAuthorizations: KeyAuthorization? = null, + @SerialName("keyInfo") + val keyInfo: Map? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt new file mode 100644 index 00000000..d69a5f8a --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt @@ -0,0 +1,120 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Convenience class with a custom serializer ([DeviceNameSpacesSerializer]) to prevent + * usage of the type `ByteStringWrapper>>` in [DeviceSigned.namespaces]. + */ +@Serializable(with = DeviceNameSpacesSerializer::class) +data class DeviceNameSpaces( + val entries: Map +) + +/** + * Serializes [DeviceNameSpaces.entries] as a map with an "inline list", + * having the usual key as key, + * but serialized instances of [DeviceSignedItemList] as the values. + */ +object DeviceNameSpacesSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + PrimitiveSerialDescriptor("key", PrimitiveKind.STRING), + DeviceSignedItemListSerializer.descriptor, + ) + + override fun serialize(encoder: Encoder, value: DeviceNameSpaces) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + encodeStringElement(descriptor, index++, it.key) + encodeSerializableElement(descriptor, index++, DeviceSignedItemList.serializer(), it.value) + } + } + } + + override fun deserialize(decoder: Decoder): DeviceNameSpaces { + val entries = mutableMapOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: DeviceSignedItemList + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeStringElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeSerializableElement(descriptor, index, DeviceSignedItemList.serializer()) + entries[key] = value + } + } + } + return DeviceNameSpaces(entries) + } +} + + +/** + * Convenience class with a custom serializer ([DeviceSignedItemListSerializer]) to prevent + * usage of the type `Map>` in [DeviceNameSpaces.entries]. + */ +@Serializable(with = DeviceSignedItemListSerializer::class) +data class DeviceSignedItemList( + val entries: List +) + +/** + * Serializes [DeviceSignedItemList.entries] as an "inline list", + * having serialized instances of [DeviceSignedItem] as the values. + */ +object DeviceSignedItemListSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + PrimitiveSerialDescriptor("key", PrimitiveKind.STRING), + PrimitiveSerialDescriptor("value", PrimitiveKind.STRING) // TODO Change to `Any` + ) + + override fun serialize(encoder: Encoder, value: DeviceSignedItemList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeStringElement(descriptor, index++, it.key) + this.encodeStringElement(descriptor, index++, it.value) + } + } + } + + override fun deserialize(decoder: Decoder): DeviceSignedItemList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: String + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeStringElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeStringElement(descriptor, index) + entries += DeviceSignedItem(key, value) + } + } + } + return DeviceSignedItemList(entries) + } +} + + +/** + * Convenience class (getting serialized in [DeviceSignedItemListSerializer]) to prevent + * usage of the type `List>` in [DeviceSignedItemList.entries]. + */ +data class DeviceSignedItem( + val key: String, + // TODO Make this `Any`, but based on the credential serializer + val value: String, +) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt index 196b03ca..3229a4d5 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceRequest.kt @@ -3,19 +3,7 @@ package at.asitplus.wallet.lib.iso import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.cosef.CoseSigned -import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper -import io.matthewnelson.encoding.base16.Base16 -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.serialization.* -import kotlinx.serialization.builtins.ByteArraySerializer -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.cbor.ByteString -import kotlinx.serialization.cbor.ValueTags -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* -import okio.ByteString.Companion.toByteString /** * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) @@ -53,525 +41,3 @@ data class DeviceRequest( } } -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DocRequest( - @SerialName("itemsRequest") - @Serializable(with = ByteStringWrapperItemsRequestSerializer::class) - @ValueTags(24U) - val itemsRequest: ByteStringWrapper, - @SerialName("readerAuth") - val readerAuth: CoseSigned? = null, -) { - override fun toString(): String { - return "DocRequest(itemsRequest=${itemsRequest.value}, readerAuth=$readerAuth)" - } - -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class ItemsRequest( - @SerialName("docType") - val docType: String, - @SerialName("nameSpaces") - val namespaces: Map, - @SerialName("requestInfo") - val requestInfo: Map? = null, -) - - -/** - * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent - * usage of the type `Map>` in [ItemsRequest.namespaces]. - */ -@Serializable(with = ItemsRequestListSerializer::class) -data class ItemsRequestList( - val entries: List -) - -/** - * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent - * usage of the type `Map>` in [ItemsRequest.namespaces]. - */ -data class SingleItemsRequest( - val key: String, - val value: Boolean, -) - -/** - * Serializes [ItemsRequestList.entries] as an "inline map", - * having [SingleItemsRequest.key] as the map key and [SingleItemsRequest.value] as the map value, - * for the map represented by [ItemsRequestList]. - */ -object ItemsRequestListSerializer : KSerializer { - - override val descriptor: SerialDescriptor = mapSerialDescriptor( - keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), - valueDescriptor = listSerialDescriptor(), - ) - - override fun serialize(encoder: Encoder, value: ItemsRequestList) { - encoder.encodeStructure(descriptor) { - var index = 0 - value.entries.forEach { - this.encodeStringElement(descriptor, index++, it.key) - this.encodeBooleanElement(descriptor, index++, it.value) - } - } - } - - override fun deserialize(decoder: Decoder): ItemsRequestList { - val entries = mutableListOf() - decoder.decodeStructure(descriptor) { - lateinit var key: String - var value: Boolean - while (true) { - val index = decodeElementIndex(descriptor) - if (index == CompositeDecoder.DECODE_DONE) { - break - } else if (index % 2 == 0) { - key = decodeStringElement(descriptor, index) - } else if (index % 2 == 1) { - value = decodeBooleanElement(descriptor, index) - entries += SingleItemsRequest(key, value) - } - } - } - return ItemsRequestList(entries) - } -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DeviceResponse( - @SerialName("version") - val version: String, - @SerialName("documents") - val documents: Array? = null, - @SerialName("documentErrors") - val documentErrors: Array>? = null, - @SerialName("status") - val status: UInt, -) { - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as DeviceResponse - - if (version != other.version) return false - if (documents != null) { - if (other.documents == null) return false - if (!documents.contentEquals(other.documents)) return false - } else if (other.documents != null) return false - if (documentErrors != null) { - if (other.documentErrors == null) return false - if (!documentErrors.contentEquals(other.documentErrors)) return false - } else if (other.documentErrors != null) return false - return status == other.status - } - - override fun hashCode(): Int { - var result = version.hashCode() - result = 31 * result + (documents?.contentHashCode() ?: 0) - result = 31 * result + (documentErrors?.contentHashCode() ?: 0) - result = 31 * result + status.hashCode() - return result - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class Document( - @SerialName("docType") - val docType: String, // this is relevant for deserializing the elementValue of IssuerSignedItem - @SerialName("issuerSigned") - val issuerSigned: IssuerSigned, - @SerialName("deviceSigned") - val deviceSigned: DeviceSigned, - @SerialName("errors") - val errors: Map>? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Document) return false - - if (docType != other.docType) return false - if (issuerSigned != other.issuerSigned) return false - if (deviceSigned != other.deviceSigned) return false - if (errors != other.errors) return false - - return true - } - - override fun hashCode(): Int { - var result = docType.hashCode() - result = 31 * result + issuerSigned.hashCode() - result = 31 * result + deviceSigned.hashCode() - result = 31 * result + (errors?.hashCode() ?: 0) - return result - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class IssuerSigned private constructor( - @SerialName("nameSpaces") - @Serializable(with = NamespacedIssuerSignedListSerializer::class) - val namespaces: Map? = null, - @SerialName("issuerAuth") - val issuerAuth: CoseSigned, -) { - - constructor( - namespacedItems: Map>, - issuerAuth: CoseSigned, - tag: Byte? = null - ) : this(issuerAuth = issuerAuth, namespaces = namespacedItems.map { (ns, value) -> - ns to IssuerSignedList( - value.map { item -> - ByteStringWrapper( - item, - item.serialize(ns).let { tag?.let { tg -> it.wrapInCborTag(tg) } ?: it }) - }) - }.toMap()) - - - fun getIssuerAuthPayloadAsMso() = issuerAuth.payload?.stripCborTag(24) - ?.let { vckCborSerializer.decodeFromByteArray(ByteStringWrapperMobileSecurityObjectSerializer, it).value } - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is IssuerSigned) return false - - if (issuerAuth != other.issuerAuth) return false - if (namespaces != other.namespaces) return false - - return true - } - - override fun hashCode(): Int { - var result = issuerAuth.hashCode() - result = 31 * result + (namespaces?.hashCode() ?: 0) - return result - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -object NamespacedIssuerSignedListSerializer : KSerializer> { - val mapSerializer = MapSerializer(String.serializer(), object : IssuerSignedListSerializer("") {}) - override val descriptor = mapSerializer.descriptor - override fun deserialize(decoder: Decoder): Map = NamespacedMapEntryDeserializer().let { - MapSerializer( - it.namespaceSerializer, - it.itemSerializer - ).deserialize(decoder) - } - - class NamespacedMapEntryDeserializer { - lateinit var key: String - - val namespaceSerializer = NamespaceSerializer() - val itemSerializer = IssuerSignedListSerializer() - - inner class NamespaceSerializer internal constructor() : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("ISO namespace", PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): String = decoder.decodeString().apply { key = this } - - override fun serialize(encoder: Encoder, value: String) { - encoder.encodeString(value).also { key = value } - } - - } - - inner class IssuerSignedListSerializer internal constructor() : KSerializer { - override val descriptor = mapSerializer.descriptor - - override fun deserialize(decoder: Decoder): IssuerSignedList = - decoder.decodeSerializableValue(IssuerSignedListSerializer(key)) - - - override fun serialize(encoder: Encoder, value: IssuerSignedList) { - encoder.encodeSerializableValue(IssuerSignedListSerializer(key), value) - } - - } - } - - - override fun serialize(encoder: Encoder, value: Map) { - mapSerializer.serialize(encoder, value) - } - -} - - -/** - * Convenience class with a custom serializer ([IssuerSignedListSerializer]) to prevent - * usage of the type `Map>>` in [namespaces]. - */ -data class IssuerSignedList( - val entries: List> -) { - override fun toString(): String { - return "IssuerSignedList(entries=${entries.map { it.value }})" - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is IssuerSignedList) return false - - if (entries != other.entries) return false - - return true - } - - override fun hashCode(): Int { - return 31 * entries.hashCode() - } -} - -/** - * Serializes [IssuerSignedList.entries] as an "inline list", - * having serialized instances of [IssuerSignedItem] as the values. - */ -open class IssuerSignedListSerializer(val namespace: String) : KSerializer { - - override val descriptor: SerialDescriptor = object : SerialDescriptor { - @ExperimentalSerializationApi - override val elementsCount: Int = 1 - - @ExperimentalSerializationApi - override val kind: SerialKind = StructureKind.LIST - - @ExperimentalSerializationApi - override val serialName: String = "kotlin.collections.ArrayList" - - @ExperimentalSerializationApi - override fun getElementAnnotations(index: Int): List { - @OptIn(ExperimentalUnsignedTypes::class) - return listOf(ValueTags(24U)) - } - - @ExperimentalSerializationApi - override fun getElementDescriptor(index: Int): SerialDescriptor { - return Byte.serializer().descriptor - } - - @ExperimentalSerializationApi - override fun getElementIndex(name: String): Int { - return name.toInt() - } - - @ExperimentalSerializationApi - override fun getElementName(index: Int): String { - return index.toString() - } - - @ExperimentalSerializationApi - override fun isElementOptional(index: Int): Boolean { - return false - } - } - - - override fun serialize(encoder: Encoder, value: IssuerSignedList) { - var index = 0 - encoder.encodeCollection(descriptor, value.entries.size) { - value.entries.forEach { - encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value.serialize(namespace)) - } - } - } - - override fun deserialize(decoder: Decoder): IssuerSignedList { - val entries = mutableListOf>() - decoder.decodeStructure(descriptor) { - while (true) { - val index = decodeElementIndex(descriptor) - if (index == CompositeDecoder.DECODE_DONE) { - break - } else { - val readBytes = decoder.decodeSerializableValue(ByteArraySerializer()) - - entries += ByteStringWrapper( - value = IssuerSignedItem.deserialize(readBytes, namespace = namespace).getOrThrow(), - serialized = readBytes - ) - } - } - } - return IssuerSignedList(entries) - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ - -data class IssuerSignedItem( - @SerialName(PROP_DIGEST_ID) - val digestId: UInt, - @SerialName(PROP_RANDOM) - @ByteString - val random: ByteArray, - @SerialName(PROP_ELEMENT_ID) - val elementIdentifier: String, - @SerialName(PROP_ELEMENT_VALUE) - val elementValue: Any, -) { - - - fun serialize(namespace: String) = vckCborSerializer.encodeToByteArray(IssuerSignedItemSerializer(namespace), this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as IssuerSignedItem - - if (digestId != other.digestId) return false - if (!random.contentEquals(other.random)) return false - if (elementIdentifier != other.elementIdentifier) return false - if(elementValue is ByteArray && other.elementValue is ByteArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is IntArray && other.elementValue is IntArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is BooleanArray && other.elementValue is BooleanArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is CharArray && other.elementValue is CharArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is ShortArray && other.elementValue is ShortArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is LongArray && other.elementValue is LongArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is FloatArray && other.elementValue is FloatArray) return elementValue.contentEquals(other.elementValue) - if(elementValue is DoubleArray && other.elementValue is DoubleArray) return elementValue.contentEquals(other.elementValue) - return if (elementValue is Array<*> && other.elementValue is Array<*>) elementValue.contentDeepEquals(other.elementValue) - //It was time for Thomas to leave. He had seen everything. - else elementValue == other.elementValue - } - - override fun hashCode(): Int { - var result = digestId.hashCode() - result = 31 * result + random.contentHashCode() - result = 31 * result + elementIdentifier.hashCode() - result = 31 * result + elementValue.hashCode() - return result - } - - override fun toString(): String { - return "IssuerSignedItem(digestId=$digestId," + - " random=${random.encodeToString(Base16(strict = true))}," + - " elementIdentifier='$elementIdentifier'," + - " elementValue=$elementValue)" - } - - companion object { - fun deserialize(it: ByteArray, namespace: String) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(IssuerSignedItemSerializer(namespace), it) - }.wrap() - - internal const val PROP_DIGEST_ID = "digestID" - internal const val PROP_RANDOM = "random" - internal const val PROP_ELEMENT_ID = "elementIdentifier" - internal const val PROP_ELEMENT_VALUE = "elementValue" - } -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DeviceSigned( - @SerialName("nameSpaces") - @ByteString - @ValueTags(24U) - val namespaces: ByteArray, - @SerialName("deviceAuth") - val deviceAuth: DeviceAuth, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as DeviceSigned - - if (!namespaces.contentEquals(other.namespaces)) return false - return deviceAuth == other.deviceAuth - } - - override fun hashCode(): Int { - var result = namespaces.contentHashCode() - result = 31 * result + deviceAuth.hashCode() - return result - } - -} - - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) - */ -@Serializable -data class DeviceAuth( - @SerialName("deviceSignature") - val deviceSignature: CoseSigned? = null, - @SerialName("deviceMac") - val deviceMac: CoseSigned? = null, // TODO is COSE_Mac0 -) - - -object ByteStringWrapperItemsRequestSerializer : KSerializer> { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ByteStringWrapperItemsRequestSerializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ByteStringWrapper) { - val bytes = vckCborSerializer.encodeToByteArray(value.value) - encoder.encodeSerializableValue(ByteArraySerializer(), bytes) - } - - override fun deserialize(decoder: Decoder): ByteStringWrapper { - val bytes = decoder.decodeSerializableValue(ByteArraySerializer()) - return ByteStringWrapper(vckCborSerializer.decodeFromByteArray(bytes), bytes) - } - -} - -fun ByteArray.stripCborTag(tag: Byte) = this.dropWhile { it == 0xd8.toByte() }.dropWhile { it == tag }.toByteArray() - -fun ByteArray.wrapInCborTag(tag: Byte) = byteArrayOf(0xd8.toByte()) + byteArrayOf(tag) + this - -fun ByteArray.sha256(): ByteArray = toByteString().sha256().toByteArray() diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceResponse.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceResponse.kt new file mode 100644 index 00000000..4214c704 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceResponse.kt @@ -0,0 +1,56 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class DeviceResponse( + @SerialName("version") + val version: String, + @SerialName("documents") + val documents: Array? = null, + @SerialName("documentErrors") + val documentErrors: Array>? = null, + @SerialName("status") + val status: UInt, +) { + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DeviceResponse + + if (version != other.version) return false + if (documents != null) { + if (other.documents == null) return false + if (!documents.contentEquals(other.documents)) return false + } else if (other.documents != null) return false + if (documentErrors != null) { + if (other.documentErrors == null) return false + if (!documentErrors.contentEquals(other.documentErrors)) return false + } else if (other.documentErrors != null) return false + return status == other.status + } + + override fun hashCode(): Int { + var result = version.hashCode() + result = 31 * result + (documents?.contentHashCode() ?: 0) + result = 31 * result + (documentErrors?.contentHashCode() ?: 0) + result = 31 * result + status.hashCode() + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt new file mode 100644 index 00000000..b950fd84 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt @@ -0,0 +1,38 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Serializable +data class DeviceSigned( + @SerialName("nameSpaces") + @ValueTags(24U) + val namespaces: ByteStringWrapper, + @SerialName("deviceAuth") + val deviceAuth: DeviceAuth, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DeviceSigned + + if (namespaces != other.namespaces) return false + if (deviceAuth != other.deviceAuth) return false + + return true + } + + override fun hashCode(): Int { + var result = namespaces.hashCode() + result = 31 * result + deviceAuth.hashCode() + return result + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DocRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DocRequest.kt new file mode 100644 index 00000000..9812b375 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DocRequest.kt @@ -0,0 +1,25 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Serializable +data class DocRequest( + @SerialName("itemsRequest") + @ValueTags(24U) + val itemsRequest: ByteStringWrapper, + @SerialName("readerAuth") + val readerAuth: CoseSigned? = null, +) { + override fun toString(): String { + return "DocRequest(itemsRequest=${itemsRequest.value}, readerAuth=$readerAuth)" + } + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/Document.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/Document.kt new file mode 100644 index 00000000..a4b2e9f2 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/Document.kt @@ -0,0 +1,50 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class Document( + @SerialName("docType") + val docType: String, // this is relevant for deserializing the elementValue of IssuerSignedItem + @SerialName("issuerSigned") + val issuerSigned: IssuerSigned, + @SerialName("deviceSigned") + val deviceSigned: DeviceSigned, + @SerialName("errors") + val errors: Map>? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Document) return false + + if (docType != other.docType) return false + if (issuerSigned != other.issuerSigned) return false + if (deviceSigned != other.deviceSigned) return false + if (errors != other.errors) return false + + return true + } + + override fun hashCode(): Int { + var result = docType.hashCode() + result = 31 * result + issuerSigned.hashCode() + result = 31 * result + deviceSigned.hashCode() + result = 31 * result + (errors?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt new file mode 100644 index 00000000..bd1596bb --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt @@ -0,0 +1,68 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.catching +import at.asitplus.signum.indispensable.cosef.CoseSigned +import kotlinx.serialization.* + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class IssuerSigned private constructor( + @SerialName("nameSpaces") + @Serializable(with = NamespacedIssuerSignedListSerializer::class) + val namespaces: Map? = null, + @SerialName("issuerAuth") + val issuerAuth: CoseSigned, +) { + fun getIssuerAuthPayloadAsMso() = catching { + MobileSecurityObject.deserializeFromIssuerAuth(issuerAuth.payload!!).getOrThrow() + } + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IssuerSigned) return false + + if (issuerAuth != other.issuerAuth) return false + if (namespaces != other.namespaces) return false + + return true + } + + override fun hashCode(): Int { + var result = issuerAuth.hashCode() + result = 31 * result + (namespaces?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + + // Note: Can't be a secondary constructor, because it would have the same JVM signature as the primary one. + /** + * Ensures the serialization of this structure in [Document.issuerSigned]: + * ``` + * IssuerNameSpaces = { ; Returned data elements for each namespace + * + NameSpace => [ + IssuerSignedItemBytes ] + * } + * IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem) + * ``` + * + * See ISO/IEC 18013-5:2021, 8.3.2.1.2.2 Device retrieval mdoc response + */ + fun fromIssuerSignedItems( + namespacedItems: Map>, + issuerAuth: CoseSigned, + ): IssuerSigned = IssuerSigned( + namespaces = namespacedItems.map { (namespace, value) -> + namespace to IssuerSignedList.fromIssuerSignedItems(value, namespace) + }.toMap(), + issuerAuth = issuerAuth, + ) + + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItem.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItem.kt new file mode 100644 index 00000000..423dd2d6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItem.kt @@ -0,0 +1,73 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.serialization.SerialName +import kotlinx.serialization.cbor.ByteString + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +data class IssuerSignedItem( + @SerialName(PROP_DIGEST_ID) + val digestId: UInt, + @SerialName(PROP_RANDOM) + @ByteString + val random: ByteArray, + @SerialName(PROP_ELEMENT_ID) + val elementIdentifier: String, + @SerialName(PROP_ELEMENT_VALUE) + val elementValue: Any, +) { + + fun serialize(namespace: String) = vckCborSerializer.encodeToByteArray(IssuerSignedItemSerializer(namespace), this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as IssuerSignedItem + + if (digestId != other.digestId) return false + if (!random.contentEquals(other.random)) return false + if (elementIdentifier != other.elementIdentifier) return false + if (elementValue is ByteArray && other.elementValue is ByteArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is IntArray && other.elementValue is IntArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is BooleanArray && other.elementValue is BooleanArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is CharArray && other.elementValue is CharArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is ShortArray && other.elementValue is ShortArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is LongArray && other.elementValue is LongArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is FloatArray && other.elementValue is FloatArray) return elementValue.contentEquals(other.elementValue) + if (elementValue is DoubleArray && other.elementValue is DoubleArray) return elementValue.contentEquals(other.elementValue) + return if (elementValue is Array<*> && other.elementValue is Array<*>) elementValue.contentDeepEquals(other.elementValue) + //It was time for Thomas to leave. He had seen everything. + else elementValue == other.elementValue + } + + override fun hashCode(): Int { + var result = digestId.hashCode() + result = 31 * result + random.contentHashCode() + result = 31 * result + elementIdentifier.hashCode() + result = 31 * result + elementValue.hashCode() + return result + } + + override fun toString(): String { + return "IssuerSignedItem(digestId=$digestId," + + " random=${random.encodeToString(Base16(strict = true))}," + + " elementIdentifier='$elementIdentifier'," + + " elementValue=$elementValue)" + } + + companion object { + fun deserialize(it: ByteArray, namespace: String) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(IssuerSignedItemSerializer(namespace), it) + }.wrap() + + internal const val PROP_DIGEST_ID = "digestID" + internal const val PROP_RANDOM = "random" + internal const val PROP_ELEMENT_ID = "elementIdentifier" + internal const val PROP_ELEMENT_VALUE = "elementValue" + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt index 06584cb6..7bb22526 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedItemSerializer.kt @@ -1,6 +1,10 @@ package at.asitplus.wallet.lib.iso import at.asitplus.wallet.lib.data.InstantStringSerializer +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_DIGEST_ID +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_ELEMENT_ID +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_ELEMENT_VALUE +import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_RANDOM import io.github.aakira.napier.Napier import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -15,10 +19,10 @@ import kotlinx.serialization.encoding.* open class IssuerSignedItemSerializer(private val namespace: String) : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("IssuerSignedItem") { - element(IssuerSignedItem.PROP_DIGEST_ID, Long.serializer().descriptor) - element(IssuerSignedItem.PROP_RANDOM, ByteArraySerializer().descriptor) - element(IssuerSignedItem.PROP_ELEMENT_ID, String.serializer().descriptor) - element(IssuerSignedItem.PROP_ELEMENT_VALUE, String.serializer().descriptor) + element(PROP_DIGEST_ID, Long.serializer().descriptor) + element(PROP_RANDOM, ByteArraySerializer().descriptor) + element(PROP_ELEMENT_ID, String.serializer().descriptor) + element(PROP_ELEMENT_VALUE, String.serializer().descriptor) } override fun serialize(encoder: Encoder, value: IssuerSignedItem) { @@ -32,15 +36,13 @@ open class IssuerSignedItemSerializer(private val namespace: String) : KSerializ private fun CompositeEncoder.encodeAnything(value: IssuerSignedItem, index: Int) { val descriptor = buildClassSerialDescriptor("IssuerSignedItem") { - element(IssuerSignedItem.PROP_DIGEST_ID, Long.serializer().descriptor) - element(IssuerSignedItem.PROP_RANDOM, ByteArraySerializer().descriptor) - element(IssuerSignedItem.PROP_ELEMENT_ID, String.serializer().descriptor) + element(PROP_DIGEST_ID, Long.serializer().descriptor) + element(PROP_RANDOM, ByteArraySerializer().descriptor) + element(PROP_ELEMENT_ID, String.serializer().descriptor) element( - elementName = IssuerSignedItem.PROP_ELEMENT_VALUE, + elementName = PROP_ELEMENT_VALUE, descriptor = buildElementValueSerializer(value.elementValue).descriptor, - annotations = if (value.elementValue is LocalDate || value.elementValue is Instant) - @OptIn(ExperimentalUnsignedTypes::class) - listOf(ValueTags(1004uL)) else emptyList() + annotations = value.elementValue.annotations() ) } @@ -55,6 +57,14 @@ open class IssuerSignedItemSerializer(private val namespace: String) : KSerializ } } + private fun Any.annotations() = + if (this is LocalDate || this is Instant) { + @OptIn(ExperimentalUnsignedTypes::class) + listOf(ValueTags(1004uL)) + } else { + emptyList() + } + private inline fun buildElementValueSerializer(element: T) = when (element) { is String -> String.serializer() is Int -> Int.serializer() @@ -75,15 +85,13 @@ open class IssuerSignedItemSerializer(private val namespace: String) : KSerializ decoder.decodeStructure(descriptor) { while (true) { val name = decodeStringElement(descriptor, 0) - val index = - descriptor.getElementIndex(name) //Don't call decodeElementIndex, as it would check for tags. this would break decodeAnything + // Don't call decodeElementIndex, as it would check for tags. this would break decodeAnything + val index = descriptor.getElementIndex(name) when (name) { - IssuerSignedItem.PROP_DIGEST_ID -> digestId = decodeLongElement(descriptor, index).toUInt() - IssuerSignedItem.PROP_RANDOM -> random = - decodeSerializableElement(descriptor, index, ByteArraySerializer()) - - IssuerSignedItem.PROP_ELEMENT_ID -> elementIdentifier = decodeStringElement(descriptor, index) - IssuerSignedItem.PROP_ELEMENT_VALUE -> elementValue = decodeAnything(index, elementIdentifier) + PROP_DIGEST_ID -> digestId = decodeLongElement(descriptor, index).toUInt() + PROP_RANDOM -> random = decodeSerializableElement(descriptor, index, ByteArraySerializer()) + PROP_ELEMENT_ID -> elementIdentifier = decodeStringElement(descriptor, index) + PROP_ELEMENT_VALUE -> elementValue = decodeAnything(index, elementIdentifier) } if (index == 3) break } @@ -99,19 +107,20 @@ open class IssuerSignedItemSerializer(private val namespace: String) : KSerializ private fun CompositeDecoder.decodeAnything(index: Int, elementIdentifier: String): Any { if (namespace.isBlank()) Napier.w { "This decoder is not namespace-aware! Unspeakable things may happen…" } - //Tags are not read out here but skipped because `decodeElementIndex` is never called, so we cannot discriminate - //technically, this should be a good thing though, because otherwise we'd consume more from the input + // Tags are not read out here but skipped because `decodeElementIndex` is never called, so we cannot + // discriminate technically, this should be a good thing though, because otherwise we'd consume more from the + // input runCatching { - - CborCredentialSerializer.decode(descriptor, index, this, elementIdentifier, namespace)?.let { - return it - } - ?: Napier.w { "Could not find a registered decoder for namespace $namespace and elementIdentifier $elementIdentifier. Falling back to defaults" } - + CborCredentialSerializer.decode(descriptor, index, this, elementIdentifier, namespace) + ?.let { return it } + ?: Napier.w { + "Could not find a registered decoder for namespace $namespace and elementIdentifier" + + " $elementIdentifier. Falling back to defaults" + } } - //These are the ones that map to different CBOR data types, the rest don't, so if it is not registered, we'll lose type information - //No others must be added here, as they could consume data from the underlying bytes + // These are the ones that map to different CBOR data types, the rest don't, so if it is not registered, we'll + // lose type information. No others must be added here, as they could consume data from the underlying bytes runCatching { return decodeStringElement(descriptor, index) } runCatching { return decodeLongElement(descriptor, index) } runCatching { return decodeDoubleElement(descriptor, index) } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedList.kt new file mode 100644 index 00000000..eda2f659 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedList.kt @@ -0,0 +1,46 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper + +/** + * Convenience class with a custom serializer ([IssuerSignedListSerializer]) to prevent + * usage of the type `Map>>` in [IssuerSigned.namespaces]. + */ +data class IssuerSignedList( + val entries: List> +) { + override fun toString(): String { + return "IssuerSignedList(entries=${entries.map { it.value }})" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IssuerSignedList) return false + + if (entries != other.entries) return false + + return true + } + + override fun hashCode(): Int { + return 31 * entries.hashCode() + } + + companion object { + /** + * Ensures the serialization of this structure in [Document.issuerSigned]: + * ``` + * IssuerNameSpaces = { ; Returned data elements for each namespace + * + NameSpace => [ + IssuerSignedItemBytes ] + * } + * IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem) + * ``` + * + * See ISO/IEC 18013-5:2021, 8.3.2.1.2.2 Device retrieval mdoc response + */ + fun fromIssuerSignedItems(items: List, namespace: String) = + IssuerSignedList(items.map { item -> + ByteStringWrapper(item, item.serialize(namespace).wrapInCborTag(24)) + }) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedListSerializer.kt new file mode 100644 index 00000000..480d1f2c --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSignedListSerializer.kt @@ -0,0 +1,82 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.cbor.ValueTags +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.* + +/** + * Serializes [IssuerSignedList.entries] as an "inline list", + * having serialized instances of [IssuerSignedItem] as the values. + */ +open class IssuerSignedListSerializer(private val namespace: String) : KSerializer { + + override val descriptor: SerialDescriptor = object : SerialDescriptor { + @ExperimentalSerializationApi + override val elementsCount: Int = 1 + + @ExperimentalSerializationApi + override val kind: SerialKind = StructureKind.LIST + + @ExperimentalSerializationApi + override val serialName: String = "kotlin.collections.ArrayList" + + @ExperimentalSerializationApi + override fun getElementAnnotations(index: Int): List { + @OptIn(ExperimentalUnsignedTypes::class) + return listOf(ValueTags(24U)) + } + + @ExperimentalSerializationApi + override fun getElementDescriptor(index: Int): SerialDescriptor { + return Byte.serializer().descriptor + } + + @ExperimentalSerializationApi + override fun getElementIndex(name: String): Int { + return name.toInt() + } + + @ExperimentalSerializationApi + override fun getElementName(index: Int): String { + return index.toString() + } + + @ExperimentalSerializationApi + override fun isElementOptional(index: Int): Boolean { + return false + } + } + + + override fun serialize(encoder: Encoder, value: IssuerSignedList) { + var index = 0 + encoder.encodeCollection(descriptor, value.entries.size) { + value.entries.forEach { + encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value.serialize(namespace)) + } + } + } + + override fun deserialize(decoder: Decoder): IssuerSignedList { + val entries = mutableListOf>() + decoder.decodeStructure(descriptor) { + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } + val readBytes = decoder.decodeSerializableValue(ByteArraySerializer()) + val issuerSignedItem = IssuerSignedItem.deserialize(readBytes, namespace).getOrThrow() + entries += ByteStringWrapper(issuerSignedItem, readBytes) + } + } + return IssuerSignedList(entries) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequest.kt new file mode 100644 index 00000000..4452cd52 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequest.kt @@ -0,0 +1,17 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) + */ +@Serializable +data class ItemsRequest( + @SerialName("docType") + val docType: String, + @SerialName("nameSpaces") + val namespaces: Map, + @SerialName("requestInfo") + val requestInfo: Map? = null, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestList.kt new file mode 100644 index 00000000..b8a587c6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestList.kt @@ -0,0 +1,12 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.Serializable + +/** + * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent + * usage of the type `Map>` in [ItemsRequest.namespaces]. + */ +@Serializable(with = ItemsRequestListSerializer::class) +data class ItemsRequestList( + val entries: List +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestListSerializer.kt new file mode 100644 index 00000000..c019e73c --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ItemsRequestListSerializer.kt @@ -0,0 +1,48 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Serializes [ItemsRequestList.entries] as an "inline map", + * having [SingleItemsRequest.key] as the map key and [SingleItemsRequest.value] as the map value, + * for the map represented by [ItemsRequestList]. + */ +object ItemsRequestListSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), + valueDescriptor = listSerialDescriptor(), + ) + + override fun serialize(encoder: Encoder, value: ItemsRequestList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeStringElement(descriptor, index++, it.key) + this.encodeBooleanElement(descriptor, index++, it.value) + } + } + } + + override fun deserialize(decoder: Decoder): ItemsRequestList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: Boolean + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeStringElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeBooleanElement(descriptor, index) + entries += SingleItemsRequest(key, value) + } + } + } + return ItemsRequestList(entries) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/KeyAuthorization.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/KeyAuthorization.kt new file mode 100644 index 00000000..9ae57d28 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/KeyAuthorization.kt @@ -0,0 +1,46 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) + */ +@Serializable +data class KeyAuthorization( + @SerialName("nameSpaces") + val namespaces: Array? = null, + @SerialName("dataElements") + val dataElements: Map>? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as KeyAuthorization + + if (namespaces != null) { + if (other.namespaces == null) return false + if (!namespaces.contentEquals(other.namespaces)) return false + } else if (other.namespaces != null) return false + return dataElements == other.dataElements + } + + override fun hashCode(): Int { + var result = namespaces?.contentHashCode() ?: 0 + result = 31 * result + (dataElements?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt index 06f4c848..a0ce114a 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/MobileSecurityObject.kt @@ -3,15 +3,9 @@ package at.asitplus.wallet.lib.iso import at.asitplus.KmmResult.Companion.wrap -import at.asitplus.signum.indispensable.cosef.CoseKey import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper -import io.matthewnelson.encoding.base16.Base16 -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.datetime.Instant +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapperSerializer import kotlinx.serialization.* -import kotlinx.serialization.builtins.ByteArraySerializer -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* /** * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) @@ -34,14 +28,32 @@ data class MobileSecurityObject( fun serialize() = vckCborSerializer.encodeToByteArray(this) - fun serializeForIssuerAuth() = - vckCborSerializer.encodeToByteArray(ByteStringWrapperMobileSecurityObjectSerializer, ByteStringWrapper(this)) - .wrapInCborTag(24) + /** + * Ensures serialization of this structure in [IssuerSigned.issuerAuth]: + * ``` + * IssuerAuth = COSE_Sign1 ; The payload is MobileSecurityObjectBytes + * MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + * ``` + * + * See ISO/IEC 18013-5:2021, 9.1.2.4 Signing method and structure for MSO + */ + fun serializeForIssuerAuth() = vckCborSerializer.encodeToByteArray( + ByteStringWrapperSerializer(serializer()), ByteStringWrapper(this) + ).wrapInCborTag(24) companion object { + /** + * Deserializes the structure from the [IssuerSigned.issuerAuth] is deserialized: + * ``` + * IssuerAuth = COSE_Sign1 ; The payload is MobileSecurityObjectBytes + * MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + * ``` + * + * See ISO/IEC 18013-5:2021, 9.1.2.4 Signing method and structure for MSO + */ fun deserializeFromIssuerAuth(it: ByteArray) = kotlin.runCatching { vckCborSerializer.decodeFromByteArray( - ByteStringWrapperMobileSecurityObjectSerializer, + ByteStringWrapperSerializer(serializer()), it.stripCborTag(24) ).value }.wrap() @@ -52,194 +64,4 @@ data class MobileSecurityObject( } } -/** - * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent - * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. - */ -@Serializable(with = ValueDigestListSerializer::class) -data class ValueDigestList( - val entries: List -) - -/** - * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent - * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. - */ -data class ValueDigest( - val key: UInt, - val value: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ValueDigest - - if (key != other.key) return false - return value.contentEquals(other.value) - } - - override fun hashCode(): Int { - var result = key.hashCode() - result = 31 * result + value.contentHashCode() - return result - } - - override fun toString(): String { - return "MobileSecurityObjectNamespaceEntry(key=$key, value=${value.encodeToString(Base16(strict = true))})" - } - - companion object { - fun fromIssuerSigned(namespace: String, value: IssuerSignedItem) = ValueDigest( - value.digestId, - value.serialize(namespace).wrapInCborTag(24).sha256() - ) - } -} - -/** - * Serialized the [ValueDigestList.entries] as an "inline map", - * meaning [ValueDigest.key] is the map key and [ValueDigest.value] the map value, - * for the map represented by [ValueDigestList] - */ -object ValueDigestListSerializer : KSerializer { - - override val descriptor: SerialDescriptor = mapSerialDescriptor( - keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), - valueDescriptor = listSerialDescriptor(), - ) - - override fun serialize(encoder: Encoder, value: ValueDigestList) { - encoder.encodeStructure(descriptor) { - var index = 0 - value.entries.forEach { - this.encodeIntElement(descriptor, index++, it.key.toInt()) - // TODO Values need to be tagged with 24 ... resulting in prefix D818 - this.encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value) - } - } - } - - override fun deserialize(decoder: Decoder): ValueDigestList { - val entries = mutableListOf() - decoder.decodeStructure(descriptor) { - var key = 0 - var value: ByteArray - while (true) { - val index = decodeElementIndex(descriptor) - if (index == CompositeDecoder.DECODE_DONE) { - break - } else if (index % 2 == 0) { - key = decodeIntElement(descriptor, index) - } else if (index % 2 == 1) { - value = decodeSerializableElement(descriptor, index, ByteArraySerializer()) - entries += ValueDigest(key.toUInt(), value) - } - } - } - return ValueDigestList(entries) - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) - */ -@Serializable -data class DeviceKeyInfo( - @SerialName("deviceKey") - val deviceKey: CoseKey, - @SerialName("keyAuthorizations") - val keyAuthorizations: KeyAuthorization? = null, - @SerialName("keyInfo") - val keyInfo: Map? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) - */ -@Serializable -data class KeyAuthorization( - @SerialName("nameSpaces") - val namespaces: Array? = null, - @SerialName("dataElements") - val dataElements: Map>? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as KeyAuthorization - - if (namespaces != null) { - if (other.namespaces == null) return false - if (!namespaces.contentEquals(other.namespaces)) return false - } else if (other.namespaces != null) return false - return dataElements == other.dataElements - } - override fun hashCode(): Int { - var result = namespaces?.contentHashCode() ?: 0 - result = 31 * result + (dataElements?.hashCode() ?: 0) - return result - } - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) - */ -@Serializable -data class ValidityInfo( - @SerialName("signed") - val signed: Instant, - @SerialName("validFrom") - val validFrom: Instant, - @SerialName("validUntil") - val validUntil: Instant, - @SerialName("expectedUpdate") - val expectedUpdate: Instant? = null, -) { - - fun serialize() = vckCborSerializer.encodeToByteArray(this) - - companion object { - fun deserialize(it: ByteArray) = kotlin.runCatching { - vckCborSerializer.decodeFromByteArray(it) - }.wrap() - } -} - - -object ByteStringWrapperMobileSecurityObjectSerializer : KSerializer> { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ByteStringWrapperMobileSecurityObjectSerializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ByteStringWrapper) { - val bytes = vckCborSerializer.encodeToByteArray(value.value) - encoder.encodeSerializableValue(ByteArraySerializer(), bytes) - } - - override fun deserialize(decoder: Decoder): ByteStringWrapper { - val bytes = decoder.decodeSerializableValue(ByteArraySerializer()) - return ByteStringWrapper(vckCborSerializer.decodeFromByteArray(bytes), bytes) - } - -} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedIssuerSignedListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedIssuerSignedListSerializer.kt new file mode 100644 index 00000000..4484423c --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedIssuerSignedListSerializer.kt @@ -0,0 +1,57 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object NamespacedIssuerSignedListSerializer : KSerializer> { + + private val mapSerializer = MapSerializer(String.serializer(), object : IssuerSignedListSerializer("") {}) + + override val descriptor = mapSerializer.descriptor + + override fun deserialize(decoder: Decoder): Map = NamespacedMapEntryDeserializer().let { + MapSerializer(it.namespaceSerializer, it.itemSerializer).deserialize(decoder) + } + + class NamespacedMapEntryDeserializer { + lateinit var key: String + + val namespaceSerializer = NamespaceSerializer() + val itemSerializer = IssuerSignedListSerializer() + + inner class NamespaceSerializer internal constructor() : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("ISO namespace", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String = decoder.decodeString().apply { key = this } + + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeString(value).also { key = value } + } + + } + + inner class IssuerSignedListSerializer internal constructor() : KSerializer { + override val descriptor = mapSerializer.descriptor + + override fun deserialize(decoder: Decoder): IssuerSignedList = + decoder.decodeSerializableValue(IssuerSignedListSerializer(key)) + + + override fun serialize(encoder: Encoder, value: IssuerSignedList) { + encoder.encodeSerializableValue(IssuerSignedListSerializer(key), value) + } + + } + } + + + override fun serialize(encoder: Encoder, value: Map) { + mapSerializer.serialize(encoder, value) + } + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerItemsRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerItemsRequest.kt new file mode 100644 index 00000000..cceaa4f2 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerItemsRequest.kt @@ -0,0 +1,17 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc request (8.3.2.2.2.1) + */ +@Serializable +data class ServerItemsRequest( + @SerialName("docType") + val docType: String, + @SerialName("nameSpaces") + val namespaces: Map>, + @SerialName("requestInfo") + val requestInfo: Map? = null, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt index 5d88507d..602cdc27 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerRequest.kt @@ -44,57 +44,3 @@ data class ServerRequest( } } -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc request (8.3.2.2.2.1) - */ -@Serializable -data class ServerItemsRequest( - @SerialName("docType") - val docType: String, - @SerialName("nameSpaces") - val namespaces: Map>, - @SerialName("requestInfo") - val requestInfo: Map? = null, -) - -/** - * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc response (8.3.2.2.2.2) - */ -@Serializable -data class ServerResponse( - @SerialName("version") - val version: String, - /** - * A single document is a [JwsSigned], whose payload may be a `MobileDrivingLicenceJws` - */ - @SerialName("documents") - val documents: Array, - @SerialName("documentErrors") - val documentErrors: Map? = null, -) { - fun serialize() = vckJsonSerializer.encodeToString(this) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ServerResponse - - if (version != other.version) return false - if (!documents.contentEquals(other.documents)) return false - return documentErrors == other.documentErrors - } - - override fun hashCode(): Int { - var result = version.hashCode() - result = 31 * result + documents.contentHashCode() - result = 31 * result + (documentErrors?.hashCode() ?: 0) - return result - } - - companion object { - fun deserialize(it: String) = kotlin.runCatching { - vckJsonSerializer.decodeFromString(it) - }.wrap() - } -} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerResponse.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerResponse.kt new file mode 100644 index 00000000..60b70c34 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ServerResponse.kt @@ -0,0 +1,49 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.wallet.lib.data.vckJsonSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Server retrieval mdoc response (8.3.2.2.2.2) + */ +@Serializable +data class ServerResponse( + @SerialName("version") + val version: String, + /** + * A single document is a [JwsSigned], whose payload may be a `MobileDrivingLicenceJws` + */ + @SerialName("documents") + val documents: Array, + @SerialName("documentErrors") + val documentErrors: Map? = null, +) { + fun serialize() = vckJsonSerializer.encodeToString(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ServerResponse + + if (version != other.version) return false + if (!documents.contentEquals(other.documents)) return false + return documentErrors == other.documentErrors + } + + override fun hashCode(): Int { + var result = version.hashCode() + result = 31 * result + documents.contentHashCode() + result = 31 * result + (documentErrors?.hashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: String) = kotlin.runCatching { + vckJsonSerializer.decodeFromString(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/SingleItemsRequest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/SingleItemsRequest.kt new file mode 100644 index 00000000..006e9d08 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/SingleItemsRequest.kt @@ -0,0 +1,10 @@ +package at.asitplus.wallet.lib.iso + +/** + * Convenience class with a custom serializer ([ItemsRequestListSerializer]) to prevent + * usage of the type `Map>` in [ItemsRequest.namespaces]. + */ +data class SingleItemsRequest( + val key: String, + val value: Boolean, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValidityInfo.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValidityInfo.kt new file mode 100644 index 00000000..ee6bf4c8 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValidityInfo.kt @@ -0,0 +1,38 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for MSO (9.1.2.4) + */ +@OptIn(ExperimentalUnsignedTypes::class) +@Serializable +data class ValidityInfo( + @SerialName("signed") + @ValueTags(0u) + val signed: Instant, + @SerialName("validFrom") + @ValueTags(0u) + val validFrom: Instant, + @SerialName("validUntil") + @ValueTags(0u) + val validUntil: Instant, + @SerialName("expectedUpdate") + @ValueTags(0u) + val expectedUpdate: Instant? = null, +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigest.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigest.kt new file mode 100644 index 00000000..650aed49 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigest.kt @@ -0,0 +1,47 @@ +package at.asitplus.wallet.lib.iso + +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString + +/** + * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent + * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. + */ +data class ValueDigest( + val key: UInt, + val value: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ValueDigest + + if (key != other.key) return false + return value.contentEquals(other.value) + } + + override fun hashCode(): Int { + var result = key.hashCode() + result = 31 * result + value.contentHashCode() + return result + } + + override fun toString(): String { + return "ValueDigest(key=$key, value=${value.encodeToString(Base16(strict = true))})" + } + + companion object { + /** + * Input for digest calculation is this structure: + * `IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem)` + * + * See ISO/IEC 18013-5:2021, 9.1.2.5 Message digest function + */ + fun fromIssuerSignedItem(value: IssuerSignedItem, namespace: String): ValueDigest = + ValueDigest( + value.digestId, + value.serialize(namespace).wrapInCborTag(24).sha256() + ) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestList.kt new file mode 100644 index 00000000..5de21307 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestList.kt @@ -0,0 +1,12 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.Serializable + +/** + * Convenience class with a custom serializer ([ValueDigestListSerializer]) to prevent + * usage of the type `Map>` in [MobileSecurityObject.valueDigests]. + */ +@Serializable(with = ValueDigestListSerializer::class) +data class ValueDigestList( + val entries: List +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestListSerializer.kt new file mode 100644 index 00000000..05487a5a --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/ValueDigestListSerializer.kt @@ -0,0 +1,49 @@ +package at.asitplus.wallet.lib.iso + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Serialized the [ValueDigestList.entries] as an "inline map", + * meaning [ValueDigest.key] is the map key and [ValueDigest.value] the map value, + * for the map represented by [ValueDigestList] + */ +object ValueDigestListSerializer : KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + keyDescriptor = PrimitiveSerialDescriptor("key", PrimitiveKind.INT), + valueDescriptor = listSerialDescriptor(), + ) + + override fun serialize(encoder: Encoder, value: ValueDigestList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeIntElement(descriptor, index++, it.key.toInt()) + this.encodeSerializableElement(descriptor, index++, ByteArraySerializer(), it.value) + } + } + } + + override fun deserialize(decoder: Decoder): ValueDigestList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + var key = 0 + var value: ByteArray + while (true) { + val index = decodeElementIndex(descriptor) + if (index == CompositeDecoder.DECODE_DONE) { + break + } else if (index % 2 == 0) { + key = decodeIntElement(descriptor, index) + } else if (index % 2 == 1) { + value = decodeSerializableElement(descriptor, index, ByteArraySerializer()) + entries += ValueDigest(key.toUInt(), value) + } + } + } + return ValueDigestList(entries) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt index f249b165..03651d4e 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.CompositeEncoder +import okio.ByteString.Companion.toByteString internal object CborCredentialSerializer { @@ -65,3 +66,17 @@ val vckCborSerializer by lazy { encodeDefaults = false } } + + +fun ByteArray.stripCborTag(tag: Byte): ByteArray { + val tagBytes = byteArrayOf(0xd8.toByte(), tag) + return if (this.take(tagBytes.size).toByteArray().contentEquals(tagBytes)) { + this.drop(tagBytes.size).toByteArray() + } else { + this + } +} + +fun ByteArray.wrapInCborTag(tag: Byte) = byteArrayOf(0xd8.toByte()) + byteArrayOf(tag) + this + +fun ByteArray.sha256(): ByteArray = toByteString().sha256().toByteArray() diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt index 59e01c66..63a3f3a4 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/IssuerSignedItemSerializationTest.kt @@ -49,13 +49,18 @@ class IssuerSignedItemSerializationTest : FreeSpec({ elementValue = Random.nextBytes(32), ) + val protectedHeader = ByteStringWrapper(CoseHeader(), CoseHeader().serialize()) + val issuerAuth = CoseSigned(protectedHeader, null, null, byteArrayOf()) val doc = Document( - uuid4().toString(), - IssuerSigned( - mapOf( - namespace to listOf(item) - ), CoseSigned(ByteStringWrapper(CoseHeader(), CoseHeader().serialize()), null, null, byteArrayOf()) - ), DeviceSigned(Random.nextBytes(32), DeviceAuth()) + docType = uuid4().toString(), + issuerSigned = IssuerSigned.fromIssuerSignedItems( + mapOf(namespace to listOf(item)), + issuerAuth + ), + deviceSigned = DeviceSigned( + ByteStringWrapper(DeviceNameSpaces(mapOf())), + DeviceAuth() + ) ) val serialized = doc.serialize() diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/IsoProcessTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/IsoProcessTest.kt new file mode 100644 index 00000000..04e9889b --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/IsoProcessTest.kt @@ -0,0 +1,240 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.signum.indispensable.cosef.CoseHeader +import at.asitplus.signum.indispensable.cosef.CoseKey +import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.signum.indispensable.cosef.toCoseKey +import at.asitplus.wallet.lib.agent.DefaultCryptoService +import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert +import at.asitplus.wallet.lib.cbor.DefaultCoseService +import at.asitplus.wallet.lib.cbor.DefaultVerifierCoseService +import at.asitplus.wallet.lib.data.ConstantIndex +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.Clock +import kotlin.random.Random + +class IsoProcessTest : FreeSpec({ + + "issue, store, present, verify" { + val wallet = Wallet() + val verifier = Verifier() + val issuer = Issuer() + + val deviceResponse = issuer.buildDeviceResponse(wallet.deviceKeyInfo) + wallet.storeMdl(deviceResponse) + + val verifierRequest = verifier.buildDeviceRequest() + val walletResponse = wallet.buildDeviceResponse(verifierRequest) + verifier.verifyResponse(walletResponse, issuer.cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + } + +}) + +class Wallet { + + private val cryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + private val coseService = DefaultCoseService(cryptoService) + + val deviceKeyInfo = DeviceKeyInfo(cryptoService.keyMaterial.publicKey.toCoseKey().getOrThrow()) + private var storedIssuerAuth: CoseSigned? = null + private var storedMdlItems: IssuerSignedList? = null + + fun storeMdl(deviceResponse: DeviceResponse) { + val document = deviceResponse.documents?.first().shouldNotBeNull() + document.docType shouldBe ConstantIndex.AtomicAttribute2023.isoDocType + val issuerAuth = document.issuerSigned.issuerAuth + this.storedIssuerAuth = issuerAuth + + issuerAuth.payload.shouldNotBeNull() + val mso = document.issuerSigned.getIssuerAuthPayloadAsMso().getOrThrow() + + val mdlItems = document.issuerSigned.namespaces?.get(ConstantIndex.AtomicAttribute2023.isoNamespace) + .shouldNotBeNull() + this.storedMdlItems = mdlItems + mso.valueDigests[ConstantIndex.AtomicAttribute2023.isoNamespace].shouldNotBeNull() + + extractDataString(mdlItems, GIVEN_NAME).shouldNotBeNull() + extractDataString(mdlItems, FAMILY_NAME).shouldNotBeNull() + } + + suspend fun buildDeviceResponse(verifierRequest: DeviceRequest): DeviceResponse { + val itemsRequest = verifierRequest.docRequests[0].itemsRequest + val isoNamespace = itemsRequest.value.namespaces[ConstantIndex.AtomicAttribute2023.isoNamespace] + .shouldNotBeNull() + val requestedKeys = isoNamespace.entries.filter { it.value }.map { it.key } + return DeviceResponse( + version = "1.0", + documents = arrayOf( + Document( + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to storedMdlItems!!.entries.filter { + it.value.elementIdentifier in requestedKeys + }.map { it.value } + ), + issuerAuth = storedIssuerAuth!! + ), + deviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces(mapOf())), + deviceAuth = DeviceAuth( + deviceSignature = coseService.createSignedCose( + payload = null, + addKeyId = false + ).getOrThrow() + ) + ) + ) + ), + status = 0U, + ) + } + +} + +class Issuer { + + val cryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + private val coseService = DefaultCoseService(cryptoService) + + suspend fun buildDeviceResponse(walletKeyInfo: DeviceKeyInfo): DeviceResponse { + val issuerSigned = listOf( + buildIssuerSignedItem(FAMILY_NAME, "Mustermann", 0U), + buildIssuerSignedItem(GIVEN_NAME, "Max", 1U), + ) + + val mso = MobileSecurityObject( + version = "1.0", + digestAlgorithm = "SHA-256", + valueDigests = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to ValueDigestList(entries = issuerSigned.map { + ValueDigest.fromIssuerSignedItem(it, ConstantIndex.AtomicAttribute2023.isoNamespace) + }) + ), + deviceKeyInfo = walletKeyInfo, + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + validityInfo = ValidityInfo( + signed = Clock.System.now(), + validFrom = Clock.System.now(), + validUntil = Clock.System.now(), + ) + ) + + return DeviceResponse( + version = "1.0", + documents = arrayOf( + Document( + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + issuerSigned = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to issuerSigned + ), + issuerAuth = coseService.createSignedCose( + payload = mso.serializeForIssuerAuth(), + addKeyId = false, + addCertificate = true, + ).getOrThrow() + ), + deviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces(mapOf())), + deviceAuth = DeviceAuth() + ) + ) + ), + status = 0U, + ) + } +} + +class Verifier { + + private val cryptoService = DefaultCryptoService(EphemeralKeyWithoutCert()) + private val coseService = DefaultCoseService(cryptoService) + private val verifierCoseService = DefaultVerifierCoseService() + + suspend fun buildDeviceRequest() = DeviceRequest( + version = "1.0", + docRequests = arrayOf( + DocRequest( + itemsRequest = ByteStringWrapper( + value = ItemsRequest( + docType = ConstantIndex.AtomicAttribute2023.isoDocType, + namespaces = mapOf( + ConstantIndex.AtomicAttribute2023.isoNamespace to ItemsRequestList( + listOf( + SingleItemsRequest(FAMILY_NAME, true), + SingleItemsRequest(GIVEN_NAME, true), + ) + ) + ) + ) + ), + readerAuth = coseService.createSignedCose( + unprotectedHeader = CoseHeader(), + payload = null, + addKeyId = false, + ).getOrThrow() + ) + ) + ) + + fun verifyResponse(deviceResponse: DeviceResponse, issuerKey: CoseKey) { + val documents = deviceResponse.documents.shouldNotBeNull() + val doc = documents.first() + doc.docType shouldBe ConstantIndex.AtomicAttribute2023.isoDocType + doc.errors.shouldBeNull() + val issuerSigned = doc.issuerSigned + val issuerAuth = issuerSigned.issuerAuth + verifierCoseService.verifyCose(issuerAuth, issuerKey).isSuccess shouldBe true + issuerAuth.payload.shouldNotBeNull() + val mso = issuerSigned.getIssuerAuthPayloadAsMso().getOrThrow() + + mso.docType shouldBe ConstantIndex.AtomicAttribute2023.isoDocType + val mdlItems = mso.valueDigests[ConstantIndex.AtomicAttribute2023.isoNamespace].shouldNotBeNull() + + val walletKey = mso.deviceKeyInfo.deviceKey + val deviceSignature = doc.deviceSigned.deviceAuth.deviceSignature.shouldNotBeNull() + verifierCoseService.verifyCose(deviceSignature, walletKey).isSuccess shouldBe true + val namespaces = issuerSigned.namespaces.shouldNotBeNull() + val issuerSignedItems = namespaces[ConstantIndex.AtomicAttribute2023.isoNamespace].shouldNotBeNull() + + extractAndVerifyData(issuerSignedItems, mdlItems, FAMILY_NAME) + extractAndVerifyData(issuerSignedItems, mdlItems, GIVEN_NAME) + } + + private fun extractAndVerifyData( + issuerSignedItems: IssuerSignedList, + mdlItems: ValueDigestList, + key: String + ) { + val issuerSignedItem = issuerSignedItems.entries.first { it.value.elementIdentifier == key } + //val elementValue = issuerSignedItem.value.elementValue.toString().shouldNotBeNull() + val issuerHash = mdlItems.entries.first { it.key == issuerSignedItem.value.digestId }.shouldNotBeNull() + val verifierHash = issuerSignedItem.serialized.sha256() + verifierHash.encodeToString(Base16(true)) shouldBe issuerHash.value.encodeToString(Base16(true)) + } +} + +private fun extractDataString( + mdlItems: IssuerSignedList, + key: String +): String { + val element = mdlItems.entries.first { it.value.elementIdentifier == key } + return element.value.elementValue.toString().shouldNotBeNull() +} + +fun buildIssuerSignedItem(elementIdentifier: String, elementValue: Any, digestId: UInt) = IssuerSignedItem( + digestId = digestId, + random = Random.nextBytes(16), + elementIdentifier = elementIdentifier, + elementValue = elementValue +) + +const val FAMILY_NAME = "family_name" +const val GIVEN_NAME = "given_name" diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag0SerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag0SerializationTest.kt new file mode 100644 index 00000000..60face50 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag0SerializationTest.kt @@ -0,0 +1,41 @@ +package at.asitplus.wallet.lib.iso + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Test correct appending tag 0 (in hex `C0`) for certain data elements, + * as defined by ISO/IEC 18013-5:2021 + */ +@OptIn(ExperimentalSerializationApi::class) +class Tag0SerializationTest : FreeSpec({ + + "ValidityInfo" { + val input = ValidityInfo( + signed = Clock.System.now(), + validFrom = Clock.System.now(), + validUntil = Clock.System.now(), + expectedUpdate = Clock.System.now(), + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + .also { println(it.encodeToString(Base16(true))) } + + val text = "78" // COSE "text" for text value, i.e. the serialized Instant + val tag0 = "C0$text" // COSE tag 0 plus "text" + val hexEncoded = serialized.encodeToString(Base16(true)) + hexEncoded.shouldContain("7369676E6564$tag0") // "signed" + hexEncoded.shouldContain("76616C696446726F6D$tag0") // "validFrom" + hexEncoded.shouldContain("76616C6964556E74696C$tag0") // "validUntil" + hexEncoded.shouldContain("6578706563746564557064617465$tag0") // "expectedUpdate" + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + +}) diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag24SerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag24SerializationTest.kt new file mode 100644 index 00000000..8c603603 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/iso/Tag24SerializationTest.kt @@ -0,0 +1,145 @@ +package at.asitplus.wallet.lib.iso + +import arrow.core.fold +import at.asitplus.signum.indispensable.cosef.* +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.wallet.lib.agent.DummyCredentialDataProvider +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert +import at.asitplus.wallet.lib.agent.Issuer +import at.asitplus.wallet.lib.agent.IssuerAgent +import at.asitplus.wallet.lib.data.ConstantIndex +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.maps.shouldNotBeEmpty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContainOnlyOnce +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeInstanceOf +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlin.random.Random + +/** + * Test correct appending tag 24 (in hex `D818`) for certain data structures, + * as defined by ISO/IEC 18013-5:2021 + */ +@OptIn(ExperimentalSerializationApi::class) +class Tag24SerializationTest : FreeSpec({ + + "DeviceSigned" { + val input = DeviceSigned( + namespaces = ByteStringWrapper( + DeviceNameSpaces( + mapOf( + "iso.namespace" to DeviceSignedItemList( + listOf( + DeviceSignedItem("name", "foo"), + DeviceSignedItem("date", "bar") + ) + ) + ) + ) + ), + deviceAuth = DeviceAuth( + deviceSignature = issuerAuth() + ) + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + .also { println(it.encodeToString(Base16(true))) } + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + + "DocRequest" { + val input = DocRequest( + itemsRequest = ByteStringWrapper(ItemsRequest("docType", mapOf(), null)), + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + .also { println(it.encodeToString(Base16(true))) } + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + + "IssuerSigned" { + val input = IssuerSigned.fromIssuerSignedItems( + namespacedItems = mapOf( + "org.iso.something" to listOf(issuerSignedItem()) + ), + issuerAuth = issuerAuth() + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + .also { println(it.encodeToString(Base16(true))) } + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + } + + "IssuerSigned from IssuerAgent" { + val issuerAgent = IssuerAgent(dataProvider = DummyCredentialDataProvider()) + val holderKeyMaterial = EphemeralKeyWithSelfSignedCert() + val issuedCredential = issuerAgent.issueCredential( + holderKeyMaterial.publicKey, + ConstantIndex.AtomicAttribute2023, + ConstantIndex.CredentialRepresentation.ISO_MDOC + ).getOrThrow().shouldBeInstanceOf() + + issuedCredential.issuerSigned.namespaces!!.shouldNotBeEmpty() + val numberOfClaims = issuedCredential.issuerSigned.namespaces!!.fold(0) { acc, entry -> + acc + entry.value.entries.size + } + println(issuedCredential.issuerSigned.serialize().encodeToString(Base16(true))) + val serialized = issuedCredential.issuerSigned.serialize().encodeToString(Base16(true)) + "D818".toRegex().findAll(serialized).toList().shouldHaveSize(numberOfClaims + 1) + // add 1 for MSO in IssuerAuth + } + + "IssuerAuth" { + val mso = MobileSecurityObject( + version = "1.0", + digestAlgorithm = "SHA-256", + valueDigests = mapOf("foo" to ValueDigestList(listOf(ValueDigest(0U, byteArrayOf())))), + deviceKeyInfo = deviceKeyInfo(), + docType = "docType", + validityInfo = ValidityInfo(Clock.System.now(), Clock.System.now(), Clock.System.now()) + ) + val serializedMso = mso.serializeForIssuerAuth() + .also { println(it.encodeToString(Base16(true))) } + val input = CoseSigned( + protectedHeader = ByteStringWrapper(CoseHeader()), + unprotectedHeader = null, + payload = serializedMso, + rawSignature = byteArrayOf() + ) + + val serialized = vckCborSerializer.encodeToByteArray(input) + .also { println(it.encodeToString(Base16(true))) } + + serialized.encodeToString(Base16(true)).shouldContainOnlyOnce("D818") + serializedMso.encodeToString(Base16(true)).shouldStartWith("D818") + vckCborSerializer.decodeFromByteArray(serialized) shouldBe input + MobileSecurityObject.deserializeFromIssuerAuth(serializedMso).getOrThrow() shouldBe mso + } + + +}) + +private fun deviceKeyInfo() = + DeviceKeyInfo(CoseKey(CoseKeyType.EC2, keyParams = CoseKeyParams.EcYBoolParams(CoseEllipticCurve.P256))) + +private fun issuerAuth() = CoseSigned( + protectedHeader = ByteStringWrapper(CoseHeader()), + unprotectedHeader = null, + payload = byteArrayOf(), + rawSignature = byteArrayOf() +) + +private fun issuerSignedItem() = IssuerSignedItem(0u, Random.nextBytes(16), "identifier", "value")