From 371155c526291cb333da33aec59c82df1b23ea66 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Tue, 12 Nov 2024 13:54:02 +0100 Subject: [PATCH] ISO: Fix serialization of device signed items --- CHANGELOG.md | 1 + .../wallet/lib/iso/DeviceNameSpaces.kt | 120 ------------------ .../asitplus/wallet/lib/iso/DeviceSigned.kt | 42 ++++++ .../lib/iso/DeviceSignedItemListSerializer.kt | 120 ++++++++++++++++++ .../wallet/lib/iso/DeviceSignedList.kt | 70 ++++++++++ .../asitplus/wallet/lib/iso/IssuerSigned.kt | 6 +- ...amespacedDeviceSignedItemListSerializer.kt | 54 ++++++++ .../cbor/DeviceSignedItemSerializationTest.kt | 72 +++++++++++ 8 files changed, 360 insertions(+), 125 deletions(-) delete mode 100644 vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt create mode 100644 vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedItemListSerializer.kt create mode 100644 vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedList.kt create mode 100644 vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedDeviceSignedItemListSerializer.kt create mode 100644 vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/DeviceSignedItemSerializationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ddf328f..a02fa02a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Release 5.2.0: - SD-JWT: Validate confirmation claims correctly + - ISO credentials: Serialize and deserialize device signed items correctly (i.e. considering the namespace of the element) Release 5.1.0: - Drop ARIES protocol implementation, and the `vck-aries` artifact 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 deleted file mode 100644 index d69a5f8a9..000000000 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceNameSpaces.kt +++ /dev/null @@ -1,120 +0,0 @@ -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/DeviceSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt index b950fd847..9056bc5b4 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSigned.kt @@ -1,9 +1,13 @@ 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 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 mdoc request (8.3.2.1.2.1) @@ -18,6 +22,8 @@ data class DeviceSigned( val deviceAuth: DeviceAuth, ) { + 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 @@ -35,4 +41,40 @@ data class DeviceSigned( result = 31 * result + deviceAuth.hashCode() 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.deviceSigned]: + * ``` + * DeviceSigned = { + * "nameSpaces" : DeviceNameSpacesBytes ; Returned data elements + * "deviceAuth" : DeviceAuth ; Contains the device authentication for mdoc authentication + * } + * DeviceNameSpaceBytes = #6.24(bstr .cbor DeviceNameSpaces) + * DeviceNameSpaces = { + * * NameSpace => DeviceSignedItems ; Returned data elements for each namespace + * } + * DeviceSignedItems = { + * + DataElementIdentifier => DataElementValue ; Returned data element identifier and value + * } + * ``` + * + * See ISO/IEC 18013-5:2021, 8.3.2.1.2.2 Device retrieval mdoc response + */ + fun fromDeviceSignedItems( + namespacedItems: Map>, + deviceAuth: CoseSigned, + ): DeviceSigned = DeviceSigned( + namespaces = ByteStringWrapper(DeviceNameSpaces( namespacedItems.map { (namespace, value) -> + namespace to DeviceSignedItemList(value) + }.toMap())), + deviceAuth = DeviceAuth(deviceAuth), + ) + } } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedItemListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedItemListSerializer.kt new file mode 100644 index 000000000..7f5a3750a --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedItemListSerializer.kt @@ -0,0 +1,120 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.wallet.lib.data.InstantStringSerializer +import io.github.aakira.napier.Napier +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.mapSerialDescriptor +import kotlinx.serialization.encoding.* + +/** + * Serializes [DeviceSignedItemList.entries] as an "inline map", + * having serialized instances of [DeviceSignedItem] as the values. + */ +open class DeviceSignedItemListSerializer(private val namespace: String) : + KSerializer { + + override val descriptor: SerialDescriptor = mapSerialDescriptor( + PrimitiveSerialDescriptor("key", PrimitiveKind.STRING), + PrimitiveSerialDescriptor("value", PrimitiveKind.STRING) + ) + + override fun serialize(encoder: Encoder, value: DeviceSignedItemList) { + encoder.encodeStructure(descriptor) { + var index = 0 + value.entries.forEach { + this.encodeStringElement(descriptor, index++, it.key) + this.encodeAnything(it, index++) + } + } + } + + + private fun CompositeEncoder.encodeAnything(value: DeviceSignedItem, index: Int) { + val elementValueSerializer = buildElementValueSerializer(namespace, value.value, value.key) + val descriptor = mapSerialDescriptor( + PrimitiveSerialDescriptor("key", PrimitiveKind.STRING), + elementValueSerializer.descriptor + ) + + when (val it = value.value) { + is String -> encodeStringElement(descriptor, index, it) + is Int -> encodeIntElement(descriptor, index, it) + is Long -> encodeLongElement(descriptor, index, it) + is LocalDate -> encodeSerializableElement(descriptor, index, LocalDate.serializer(), it) + is Instant -> encodeSerializableElement(descriptor, index, InstantStringSerializer(), it) + is Boolean -> encodeBooleanElement(descriptor, index, it) + is ByteArray -> encodeSerializableElement(descriptor, index, ByteArraySerializer(), it) + else -> CborCredentialSerializer.encode(namespace, value.key, descriptor, index, this, it) + } + } + + private inline fun buildElementValueSerializer( + namespace: String, + elementValue: T, + elementIdentifier: String + ) = when (elementValue) { + is String -> String.serializer() + is Int -> Int.serializer() + is Long -> Long.serializer() + is LocalDate -> LocalDate.serializer() + is Instant -> InstantStringSerializer() + is Boolean -> Boolean.serializer() + is ByteArray -> ByteArraySerializer() + is Any -> CborCredentialSerializer.lookupSerializer(namespace, elementIdentifier) + ?: error("serializer not found for $elementIdentifier, with value $elementValue") + + else -> error("serializer not found for $elementIdentifier, with value $elementValue") + } + + override fun deserialize(decoder: Decoder): DeviceSignedItemList { + val entries = mutableListOf() + decoder.decodeStructure(descriptor) { + lateinit var key: String + var value: Any + 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 = decodeAnything(index, key) + entries += DeviceSignedItem(key, value) + } + } + } + return DeviceSignedItemList(entries) + } + + 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 + elementIdentifier?.let { + CborCredentialSerializer.decode(descriptor, index, this, elementIdentifier, namespace) + ?.let { return it } + ?: Napier.v( + "Falling back to defaults for namespace $namespace and elementIdentifier $elementIdentifier" + ) + } + + // 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) } + runCatching { return decodeBooleanElement(descriptor, index) } + + throw IllegalArgumentException("Could not decode value at $index") + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedList.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedList.kt new file mode 100644 index 000000000..1fe243edb --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DeviceSignedList.kt @@ -0,0 +1,70 @@ +package at.asitplus.wallet.lib.iso + +import at.asitplus.KmmResult.Companion.wrap +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Convenience class to prevent + * usage of the type `ByteStringWrapper>>` in [DeviceSigned.namespaces]. + */ +@Serializable +data class DeviceNameSpaces( + @Serializable(with = NamespacedDeviceSignedItemListSerializer::class) + val entries: Map +) { + + fun serialize() = vckCborSerializer.encodeToByteArray(this) + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + vckCborSerializer.decodeFromByteArray(it) + }.wrap() + } +} + + +/** + * Convenience class with a custom serializer ([DeviceSignedItemListSerializer]) to prevent + * usage of the type `Map>` in [DeviceNameSpaces.entries]. + */ +data class DeviceSignedItemList( + val entries: List +) + +/** + * Convenience class (getting serialized in [DeviceSignedItemListSerializer]) to prevent + * usage of the type `List>` in [DeviceSignedItemList.entries]. + */ +data class DeviceSignedItem( + val key: String, + val value: Any, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DeviceSignedItem + + if (key != other.key) return false + if (value is ByteArray && other.value is ByteArray) return value.contentEquals(other.value) + if (value is IntArray && other.value is IntArray) return value.contentEquals(other.value) + if (value is BooleanArray && other.value is BooleanArray) return value.contentEquals(other.value) + if (value is CharArray && other.value is CharArray) return value.contentEquals(other.value) + if (value is ShortArray && other.value is ShortArray) return value.contentEquals(other.value) + if (value is LongArray && other.value is LongArray) return value.contentEquals(other.value) + if (value is FloatArray && other.value is FloatArray) return value.contentEquals(other.value) + if (value is DoubleArray && other.value is DoubleArray) return value.contentEquals(other.value) + return if (value is Array<*> && other.value is Array<*>) value.contentDeepEquals(other.value) + else value == other.value + } + + override fun hashCode(): Int { + var result = key.hashCode() + result = 31 * result + value.hashCode() + return result + } +} 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 index c9f7025cc..1788d9849 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/IssuerSigned.kt @@ -3,11 +3,7 @@ package at.asitplus.wallet.lib.iso import at.asitplus.KmmResult.Companion.wrap import at.asitplus.catching import at.asitplus.signum.indispensable.cosef.CoseSigned -import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper -import at.asitplus.wallet.lib.iso.IssuerSignedItem.Companion.PROP_ELEMENT_ID import kotlinx.serialization.* -import net.orandja.obor.codec.Cbor -import net.orandja.obor.data.* /** * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mdoc request (8.3.2.1.2.1) @@ -25,6 +21,7 @@ data class IssuerSigned private constructor( } fun serialize() = vckCborSerializer.encodeToByteArray(this) + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is IssuerSigned) return false @@ -68,6 +65,5 @@ data class IssuerSigned private constructor( }.toMap(), issuerAuth = issuerAuth, ) - } } \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedDeviceSignedItemListSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedDeviceSignedItemListSerializer.kt new file mode 100644 index 000000000..a1c57477c --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/NamespacedDeviceSignedItemListSerializer.kt @@ -0,0 +1,54 @@ +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 NamespacedDeviceSignedItemListSerializer : KSerializer> { + + private val mapSerializer = MapSerializer(String.serializer(), object : DeviceSignedItemListSerializer("") {}) + + 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 = DeviceSignedItemListSerializer() + + 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 DeviceSignedItemListSerializer internal constructor() : KSerializer { + override val descriptor = mapSerializer.descriptor + + override fun deserialize(decoder: Decoder): DeviceSignedItemList = + decoder.decodeSerializableValue(DeviceSignedItemListSerializer(key)) + + override fun serialize(encoder: Encoder, value: DeviceSignedItemList) { + encoder.encodeSerializableValue(DeviceSignedItemListSerializer(key), value) + } + } + } + + override fun serialize(encoder: Encoder, value: Map) = + NamespacedMapEntryDeserializer().let { + MapSerializer(it.namespaceSerializer, it.itemSerializer).serialize(encoder, value) + } +} \ No newline at end of file diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/DeviceSignedItemSerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/DeviceSignedItemSerializationTest.kt new file mode 100644 index 000000000..d448808f0 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/DeviceSignedItemSerializationTest.kt @@ -0,0 +1,72 @@ +package at.asitplus.wallet.lib.cbor + +import at.asitplus.signum.indispensable.cosef.CoseHeader +import at.asitplus.signum.indispensable.cosef.CoseSigned +import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper +import at.asitplus.wallet.lib.iso.* +import com.benasher44.uuid.uuid4 +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlin.random.Random +import kotlin.random.nextUInt + +class DeviceSignedItemSerializationTest : FreeSpec({ + + "serialization with String" { + val namespace = uuid4().toString() + val elementIdentifier = uuid4().toString() + val item = DeviceSignedItem( + key = elementIdentifier, + value = uuid4().toString(), + ) + val deviceNameSpaces = DeviceNameSpaces(mapOf(namespace to DeviceSignedItemList(listOf(item)))) + + val serialized = deviceNameSpaces.serialize() + serialized.encodeToString(Base16(true)).shouldNotContain("D903EC") + + val parsed = DeviceNameSpaces.deserialize(serialized).getOrThrow() + + parsed shouldBe deviceNameSpaces + } + + "document serialization with ByteArray" { + val elementId = uuid4().toString() + val namespace = uuid4().toString() + CborCredentialSerializer.register(mapOf(elementId to ByteArraySerializer()), namespace) + val issuerSignedItem = IssuerSignedItem( + digestId = Random.nextUInt(), + random = Random.nextBytes(16), + elementIdentifier = elementId, + elementValue = Random.nextBytes(32), + ) + val deviceSignedItem = DeviceSignedItem( + key = elementId, + value = Random.nextBytes(32), + ) + val protectedHeader = ByteStringWrapper(CoseHeader(), CoseHeader().serialize()) + val issuerAuth = CoseSigned(protectedHeader, null, null, byteArrayOf()) + val deviceAuth = CoseSigned(protectedHeader, null, null, byteArrayOf()) + + val doc = Document( + docType = uuid4().toString(), + issuerSigned = IssuerSigned.fromIssuerSignedItems( + mapOf(namespace to listOf(issuerSignedItem)), + issuerAuth + ), + deviceSigned = DeviceSigned.fromDeviceSignedItems( + mapOf(namespace to listOf(deviceSignedItem)), + deviceAuth + ) + ) + val serialized = doc.serialize() + serialized.encodeToString(Base16(true)).shouldNotContain("D903EC") + + val parsed = Document.deserialize(serialized).getOrThrow() + + parsed shouldBe doc + } +})