From 44a7d09659524778bc18274c0dd0a66ba941eb68 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Thu, 13 Jul 2023 13:50:27 +0200 Subject: [PATCH] Add data classes for CBOR/COSE signing --- .../asitplus/wallet/lib/cbor/CoseAlgorithm.kt | 35 ++++++ .../wallet/lib/cbor/CoseEllipticCurve.kt | 37 ++++++ .../at/asitplus/wallet/lib/cbor/CoseHeader.kt | 83 +++++++++++++ .../at/asitplus/wallet/lib/cbor/CoseKey.kt | 115 ++++++++++++++++++ .../wallet/lib/cbor/CoseKeyOperation.kt | 41 +++++++ .../asitplus/wallet/lib/cbor/CoseKeyType.kt | 35 ++++++ .../at/asitplus/wallet/lib/cbor/CoseSigned.kt | 85 +++++++++++++ .../wallet/lib/iso/DrivingPrivilege.kt | 2 +- .../asitplus/wallet/lib/iso/cborSerializer.kt | 2 +- .../wallet/lib/cbor/CoseSerializationTest.kt | 53 ++++++++ 10 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseAlgorithm.kt create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseEllipticCurve.kt create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseHeader.kt create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKey.kt create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyOperation.kt create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyType.kt create mode 100644 vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseSigned.kt create mode 100644 vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseSerializationTest.kt diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseAlgorithm.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseAlgorithm.kt new file mode 100644 index 000000000..b745074cc --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseAlgorithm.kt @@ -0,0 +1,35 @@ +package at.asitplus.wallet.lib.cbor + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = CoseAlgorithmSerializer::class) +enum class CoseAlgorithm(val value: Int) { + + ES256(-7), + ES384(-35), + ES512(-36); + +} + + +object CoseAlgorithmSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CoseAlgorithmSerializer", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: CoseAlgorithm) { + value.let { encoder.encodeInt(it.value) } + } + + override fun deserialize(decoder: Decoder): CoseAlgorithm { + val decoded = decoder.decodeInt() + return CoseAlgorithm.values().first { it.value == decoded } + } + +} \ No newline at end of file diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseEllipticCurve.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseEllipticCurve.kt new file mode 100644 index 000000000..a9fea9342 --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseEllipticCurve.kt @@ -0,0 +1,37 @@ +package at.asitplus.wallet.lib.cbor + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = CoseEllipticCurveSerializer::class) +enum class CoseEllipticCurve(val value: Int) { + + P256(1), + P384(2), + P521(3), + X25519(4), + X448(5), + Ed25519(6), + Ed448(7); + +} + +object CoseEllipticCurveSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CoseEllipticCurveSerializer", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: CoseEllipticCurve?) { + value?.let { encoder.encodeInt(it.value) } + } + + override fun deserialize(decoder: Decoder): CoseEllipticCurve? { + val decoded = decoder.decodeInt() + return CoseEllipticCurve.values().firstOrNull { it.value == decoded } + } +} \ No newline at end of file diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseHeader.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseHeader.kt new file mode 100644 index 000000000..7d9b69771 --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseHeader.kt @@ -0,0 +1,83 @@ +package at.asitplus.wallet.lib.cbor + +import at.asitplus.wallet.lib.iso.cborSerializer +import io.github.aakira.napier.Napier +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.SerialLabel +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +/** + * Protected header of a [CoseSigned]. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class CoseHeader( + @SerialLabel(1) + @SerialName("alg") + val algorithm: CoseAlgorithm? = null, + @SerialLabel(2) + @SerialName("crit") + val criticalHeaders: String? = null, + @SerialLabel(3) + @SerialName("content type") + val contentType: String? = null, + @SerialLabel(4) + @SerialName("kid") + val kid: String? = null, + @SerialLabel(5) + @SerialName("IV") + @ByteString + val iv: ByteArray? = null, + @SerialLabel(6) + @SerialName("Partial IV") + @ByteString + val partialIv: ByteArray? = null, +) { + + fun serialize() = cborSerializer.encodeToByteArray(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as CoseHeader + + if (algorithm != other.algorithm) return false + if (criticalHeaders != other.criticalHeaders) return false + if (contentType != other.contentType) return false + if (kid != other.kid) return false + if (iv != null) { + if (other.iv == null) return false + if (!iv.contentEquals(other.iv)) return false + } else if (other.iv != null) return false + if (partialIv != null) { + if (other.partialIv == null) return false + if (!partialIv.contentEquals(other.partialIv)) return false + } else if (other.partialIv != null) return false + + return true + } + + override fun hashCode(): Int { + var result = algorithm?.hashCode() ?: 0 + result = 31 * result + (criticalHeaders?.hashCode() ?: 0) + result = 31 * result + (contentType?.hashCode() ?: 0) + result = 31 * result + (kid?.hashCode() ?: 0) + result = 31 * result + (iv?.contentHashCode() ?: 0) + result = 31 * result + (partialIv?.contentHashCode() ?: 0) + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + cborSerializer.decodeFromByteArray(it) + }.getOrElse { + Napier.w("deserialize failed", it) + null + } + } +} \ No newline at end of file diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKey.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKey.kt new file mode 100644 index 000000000..c978a7df0 --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKey.kt @@ -0,0 +1,115 @@ +package at.asitplus.wallet.lib.cbor + +import at.asitplus.wallet.lib.iso.cborSerializer +import io.github.aakira.napier.Napier +import io.matthewnelson.component.base64.encodeBase64 +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.SerialLabel +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class CoseKey( + @SerialLabel(1) + @SerialName("kty") + val type: CoseKeyType, + @SerialLabel(2) + @SerialName("kid") + @ByteString + val keyId: ByteArray? = null, + @SerialLabel(3) + @SerialName("alg") + val algorithm: CoseAlgorithm? = null, + @SerialLabel(4) + @SerialName("key_ops") + val operations: Array? = null, + @SerialLabel(5) + @SerialName("Base IV") + @ByteString + val baseIv: ByteArray? = null, + @SerialLabel(-1) + @SerialName("crv") + val curve: CoseEllipticCurve? = null, + @SerialLabel(-2) + @SerialName("x") + val x: ByteArray? = null, + @SerialLabel(-3) + @SerialName("y") // TODO might also be bool + val y: ByteArray? = null, + @SerialLabel(-4) + @SerialName("d") + val d: ByteArray? = null, +) { + fun serialize() = cborSerializer.encodeToByteArray(this) + + companion object { + + fun deserialize(it: ByteArray) = kotlin.runCatching { + cborSerializer.decodeFromByteArray(it) + }.getOrElse { + Napier.w("deserialize failed", it) + null + } + + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as CoseKey + + if (type != other.type) return false + if (keyId != null) { + if (other.keyId == null) return false + if (!keyId.contentEquals(other.keyId)) return false + } else if (other.keyId != null) return false + if (algorithm != other.algorithm) return false + if (operations != null) { + if (other.operations == null) return false + if (!operations.contentEquals(other.operations)) return false + } else if (other.operations != null) return false + if (baseIv != null) { + if (other.baseIv == null) return false + if (!baseIv.contentEquals(other.baseIv)) return false + } else if (other.baseIv != null) return false + if (curve != other.curve) return false + if (x != null) { + if (other.x == null) return false + if (!x.contentEquals(other.x)) return false + } else if (other.x != null) return false + if (y != null) { + if (other.y == null) return false + if (!y.contentEquals(other.y)) return false + } else if (other.y != null) return false + if (d != null) { + if (other.d == null) return false + if (!d.contentEquals(other.d)) return false + } else if (other.d != null) return false + + return true + } + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + (keyId?.contentHashCode() ?: 0) + result = 31 * result + (algorithm?.hashCode() ?: 0) + result = 31 * result + (operations?.contentHashCode() ?: 0) + result = 31 * result + (baseIv?.contentHashCode() ?: 0) + result = 31 * result + (curve?.hashCode() ?: 0) + result = 31 * result + (x?.contentHashCode() ?: 0) + result = 31 * result + (y?.contentHashCode() ?: 0) + result = 31 * result + (d?.contentHashCode() ?: 0) + return result + } + + override fun toString(): String { + return "CoseKey(type=$type, keyId=${keyId?.encodeBase64()}, algorithm=$algorithm, operations=${operations?.contentToString()}, baseIv=${baseIv?.encodeBase64()}, curve=$curve, x=${x?.encodeBase64()}, y=${y?.encodeBase64()}, d=${d?.encodeBase64()})" + } + + +} diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyOperation.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyOperation.kt new file mode 100644 index 000000000..c691d1ce1 --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyOperation.kt @@ -0,0 +1,41 @@ +package at.asitplus.wallet.lib.cbor + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = CoseKeyOperationSerializer::class) +enum class CoseKeyOperation(val value: Int) { + + SIGN(1), + VERIFY(2), + ENCRYPT(3), + DECRYPT(4), + WRAP_KEY(5), + UNWRAP_KEY(6), + DERIVE_KEY(7), + DERIVE_BITS(8), + MAC_CREATE(9), + MAC_VERIFY(10); +} + + +object CoseKeyOperationSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CoseKeyOperationSerializer", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: CoseKeyOperation) { + value.let { encoder.encodeInt(it.value) } + } + + override fun deserialize(decoder: Decoder): CoseKeyOperation { + val decoded = decoder.decodeInt() + return CoseKeyOperation.values().first { it.value == decoded } + } + +} \ No newline at end of file diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyType.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyType.kt new file mode 100644 index 000000000..8d713b52e --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKeyType.kt @@ -0,0 +1,35 @@ +package at.asitplus.wallet.lib.cbor + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = CoseKeyTypeSerializer::class) +enum class CoseKeyType(val value: Int) { + + OKP(1), + EC2(2), + SYMMETRIC(4), + RESERVED(0); + +} + +object CoseKeyTypeSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CoseKeyTypeSerializer", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: CoseKeyType) { + value.let { encoder.encodeInt(it.value) } + } + + override fun deserialize(decoder: Decoder): CoseKeyType { + val decoded = decoder.decodeInt() + return CoseKeyType.values().firstOrNull { it.value == decoded } + ?: throw IllegalArgumentException("Not known: $decoded") + } +} \ No newline at end of file diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseSigned.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseSigned.kt new file mode 100644 index 000000000..ded40947e --- /dev/null +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseSigned.kt @@ -0,0 +1,85 @@ +package at.asitplus.wallet.lib.cbor + +import at.asitplus.wallet.lib.iso.cborSerializer +import io.github.aakira.napier.Napier +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.cbor.ByteString +import kotlinx.serialization.cbor.ByteStringWrapper +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.cbor.CborArray +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Representation of a signed COSE_Sign1 object, i.e. consisting of protected header, unprotected header and payload. + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@CborArray(18U) +data class CoseSigned( + @Serializable(with = ByteStringWrapperSerializer::class) + @ByteString + val protectedHeader: ByteStringWrapper, + val unprotectedHeader: CoseHeader? = null, + @ByteString + val payload: ByteArray, + @ByteString + val signature: ByteArray, +) { + + fun serialize() = cborSerializer.encodeToByteArray(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as CoseSigned + + if (protectedHeader != other.protectedHeader) return false + if (unprotectedHeader != other.unprotectedHeader) return false + if (!payload.contentEquals(other.payload)) return false + return signature.contentEquals(other.signature) + } + + override fun hashCode(): Int { + var result = protectedHeader.hashCode() + result = 31 * result + (unprotectedHeader?.hashCode() ?: 0) + result = 31 * result + payload.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } + + companion object { + fun deserialize(it: ByteArray) = kotlin.runCatching { + cborSerializer.decodeFromByteArray(it) + }.getOrElse { + Napier.w("deserialize failed", it) + null + } + } +} + +object ByteStringWrapperSerializer : KSerializer> { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ByteStringWrapperCoseHeaderSerializer", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ByteStringWrapper) { + val bytes = Cbor.encodeToByteArray(value.value) + encoder.encodeSerializableValue(ByteArraySerializer(), bytes) + } + + override fun deserialize(decoder: Decoder): ByteStringWrapper { + val bytes = decoder.decodeSerializableValue(ByteArraySerializer()) + return ByteStringWrapper(Cbor.decodeFromByteArray(bytes), bytes) + } + +} diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DrivingPrivilege.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DrivingPrivilege.kt index 657906896..17d2378dd 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DrivingPrivilege.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/DrivingPrivilege.kt @@ -21,7 +21,7 @@ import kotlinx.serialization.encodeToByteArray data class DrivingPrivilege constructor( @SerialName("vehicle_category_code") val vehicleCategoryCode: String, - @ValueTags(REGEX) + @ValueTags(1004u) @SerialName("issue_date") val issueDate: LocalDate? = null, @ValueTags(1004u) diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt index 70cbf7de9..ff4459ded 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/iso/cborSerializer.kt @@ -7,6 +7,6 @@ import kotlinx.serialization.cbor.Cbor val cborSerializer by lazy { Cbor { ignoreUnknownKeys = true - encodeDefaults = true + encodeDefaults = false } } \ No newline at end of file diff --git a/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseSerializationTest.kt b/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseSerializationTest.kt new file mode 100644 index 000000000..b14156d25 --- /dev/null +++ b/vclib/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/CoseSerializationTest.kt @@ -0,0 +1,53 @@ +package at.asitplus.wallet.lib.cbor + +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.matthewnelson.component.encoding.base16.decodeBase16ToArray +import io.matthewnelson.component.encoding.base16.encodeBase16 +import kotlinx.serialization.cbor.ByteStringWrapper + +// TODO Test once serialization library is correct +//class CoseSerializationTest : FreeSpec({ +// +// Napier.base(DebugAntilog()) +// +// "Serialization is correct" { +// val cose = CoseSigned( +// protectedHeader = ByteStringWrapper(CoseHeader(algorithm = CoseAlgorithm.ES256)), +// unprotectedHeader = CoseHeader(), +// payload = "This is the content.".encodeToByteArray(), +// signature = "bar".encodeToByteArray() +// ) +// val serialized = cose.serialize().encodeBase16().uppercase() +// +// serialized shouldContain "546869732069732074686520636F6E74656E742E" // "This is the content." +// // TODO serialized shouldContain "43A10126" // +// } +// +// "Serialize header" { +// val header = CoseHeader(algorithm = CoseAlgorithm.ES256, kid = "11") +// val serialized = header.serialize().encodeBase16().uppercase() +// println(serialized) +// +// val deserialized = CoseHeader.deserialize(header.serialize()) +// deserialized.shouldNotBeNull() +// deserialized.algorithm shouldBe header.algorithm +// deserialized.kid shouldBe header.kid +// } +// +// "Deserialization is correct" { +// val input = "d28443a10126a10442313154546869732069732074686520636f6e74656e" + +// "742e58408eb33e4ca31d1c465ab05aac34cc6b23d58fef5c083106c4d25a" + +// "91aef0b0117e2af9a291aa32e14ab834dc56ed2a223444547e01f11d3b09" + +// "16e5a4c345cacb36" +// val cose = CoseSigned.deserialize(input.uppercase().decodeBase16ToArray()!!) +// +// println(cose) +// cose.shouldNotBeNull() +// } +// +//})