From 055db87ac8def943fd35178e6e0d1ffd2e37bb40 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 12 Sep 2023 16:29:36 +0100 Subject: [PATCH] integrate ASN.1 --- app/build.gradle | 13 +- .../letro/utils/asn1/ASN1Exception.kt | 3 + .../relaycorp/letro/utils/asn1/ASN1Utils.kt | 73 ++++++++ .../tech/relaycorp/letro/ExampleUnitTest.kt | 16 -- .../letro/utils/asn1/ASN1UtilsTest.kt | 158 ++++++++++++++++++ 5 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Exception.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt delete mode 100644 app/src/test/java/tech/relaycorp/letro/ExampleUnitTest.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt diff --git a/app/build.gradle b/app/build.gradle index c541dae2..1b26174e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -60,6 +60,11 @@ android { abortOnError true lintConfig file('lint.xml') } + testOptions { + unitTests.all { + useJUnitPlatform() + } + } } dependencies { @@ -72,6 +77,9 @@ dependencies { // Awala implementation 'com.github.relaycorp:awala-endpoint-android:1.13.22' + // Letro messaging + implementation("org.bouncycastle:bcprov-jdk15on:1.70") + // Compose implementation platform('androidx.compose:compose-bom:2023.06.01') implementation 'androidx.compose.ui:ui' @@ -97,13 +105,16 @@ dependencies { androidTestImplementation "androidx.room:room-testing:$room_version" // Testing - testImplementation 'junit:junit:4.13.2' + def junitVersion = "5.8.2" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation platform('androidx.compose:compose-bom:2023.06.01') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' + testImplementation 'io.kotest:kotest-assertions-core:5.7.2' } // Allow references to generated code diff --git a/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Exception.kt b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Exception.kt new file mode 100644 index 00000000..f2541b31 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Exception.kt @@ -0,0 +1,3 @@ +package tech.relaycorp.letro.utils.asn1 + +class ASN1Exception(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt new file mode 100644 index 00000000..0ee00f7e --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt @@ -0,0 +1,73 @@ +package tech.relaycorp.letro.utils.asn1 + +import java.io.IOException +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.ASN1EncodableVector +import org.bouncycastle.asn1.ASN1InputStream +import org.bouncycastle.asn1.ASN1OctetString +import org.bouncycastle.asn1.ASN1Sequence +import org.bouncycastle.asn1.ASN1TaggedObject +import org.bouncycastle.asn1.ASN1VisibleString +import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERTaggedObject + +internal object ASN1Utils { + fun makeSequence(items: List, explicitTagging: Boolean = true): DERSequence { + val messagesVector = ASN1EncodableVector(items.size) + val finalItems = if (explicitTagging) items else items.mapIndexed { index, item -> + DERTaggedObject(false, index, item) + } + finalItems.forEach { messagesVector.add(it) } + return DERSequence(messagesVector) + } + + fun serializeSequence(items: List, explicitTagging: Boolean = true): ByteArray { + return makeSequence(items, explicitTagging).encoded + } + + @Throws(ASN1Exception::class) + fun deserializeSequence(serialization: ByteArray): ASN1Sequence { + if (serialization.isEmpty()) { + throw ASN1Exception("Value is empty") + } + val asn1InputStream = ASN1InputStream(serialization) + val asn1Value = try { + asn1InputStream.readObject() + } catch (_: IOException) { + throw ASN1Exception("Value is not DER-encoded") + } + return try { + ASN1Sequence.getInstance(asn1Value) + } catch (_: IllegalArgumentException) { + throw ASN1Exception("Value is not an ASN.1 sequence") + } + } + + @Throws(ASN1Exception::class) + inline fun deserializeHomogeneousSequence( + serialization: ByteArray + ): Array { + val sequence = deserializeSequence(serialization) + return sequence.map { + if (it !is T) { + throw ASN1Exception( + "Sequence contains an item of an unexpected type " + + "(${it::class.java.simpleName})" + ) + } + @Suppress("USELESS_CAST") + it as T + }.toTypedArray() + } + + @Throws(ASN1Exception::class) + fun deserializeHeterogeneousSequence(serialization: ByteArray): Array = + deserializeHomogeneousSequence(serialization) + + fun getVisibleString(visibleString: ASN1TaggedObject): ASN1VisibleString = + ASN1VisibleString.getInstance(visibleString, false) + + fun getOctetString(octetString: ASN1TaggedObject): ASN1OctetString = + DEROctetString.getInstance(octetString, false) +} diff --git a/app/src/test/java/tech/relaycorp/letro/ExampleUnitTest.kt b/app/src/test/java/tech/relaycorp/letro/ExampleUnitTest.kt deleted file mode 100644 index caa20c40..00000000 --- a/app/src/test/java/tech/relaycorp/letro/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package tech.relaycorp.letro - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt new file mode 100644 index 00000000..f9ee9e0c --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt @@ -0,0 +1,158 @@ +package tech.relaycorp.letro.utils.asn1 + +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf +import org.bouncycastle.asn1.ASN1Sequence +import org.bouncycastle.asn1.ASN1StreamParser +import org.bouncycastle.asn1.ASN1TaggedObject +import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DEROctetStringParser +import org.bouncycastle.asn1.DERVisibleString +import org.bouncycastle.asn1.DLSequenceParser +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +internal class ASN1UtilsTest { + val visibleString = DERVisibleString("foo") + val octetString = DEROctetString("bar".toByteArray()) + + @Nested + inner class MakeSequence { + @Test + fun `Values should be explicitly tagged by default`() { + val sequence = ASN1Utils.makeSequence(listOf(visibleString, octetString)) + + sequence.size() shouldBe 2 + + val item1 = sequence.getObjectAt(0) + item1 should beInstanceOf() + visibleString.string shouldBe (item1 as DERVisibleString).string + + val item2 = sequence.getObjectAt(1) + item2 should beInstanceOf() + octetString.octets shouldBe (item2 as DEROctetString).octets + } + + @Test + fun `Implicitly-tagged values should be supported`() { + val sequence = ASN1Utils.makeSequence(listOf(visibleString, octetString), false) + + sequence.size() shouldBe 2 + + val item1 = ASN1Utils.getVisibleString(sequence.getObjectAt(0) as ASN1TaggedObject) + visibleString.string shouldBe item1.string + + val item2 = ASN1Utils.getOctetString(sequence.getObjectAt(1) as ASN1TaggedObject) + octetString.octets shouldBe item2.octets + } + } + + @Nested + inner class SerializeSequence { + @Test + fun `Values should be explicitly tagged by default`() { + val serialization = ASN1Utils.serializeSequence(listOf(visibleString, octetString)) + + val parser = ASN1StreamParser(serialization) + val sequence = parser.readObject() as DLSequenceParser + + val item1 = sequence.readObject() + item1 should beInstanceOf() + visibleString.string shouldBe (item1 as DERVisibleString).string + + val item2 = sequence.readObject() + item2 should beInstanceOf() + octetString.octets shouldBe + ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets + } + + @Test + fun `Implicitly-tagged values should be supported`() { + val serialization = + ASN1Utils.serializeSequence(listOf(visibleString, octetString), false) + + val parser = ASN1StreamParser(serialization) + val sequence = + ASN1Sequence.getInstance(parser.readObject() as DLSequenceParser).toArray() + + val item1 = ASN1Utils.getVisibleString(sequence[0] as ASN1TaggedObject) + visibleString.string shouldBe item1.string + + val item2 = ASN1Utils.getOctetString(sequence[1] as ASN1TaggedObject) + octetString.octets shouldBe item2.octets + } + } + + @Nested + inner class DeserializeSequence { + @Test + fun `Value should be refused if it's empty`() { + val exception = assertThrows { + ASN1Utils.deserializeHeterogeneousSequence(byteArrayOf()) + } + + "Value is empty" shouldBe exception.message + } + + @Test + fun `Value should be refused if it's not DER-encoded`() { + val exception = assertThrows { + ASN1Utils.deserializeHeterogeneousSequence("a".toByteArray()) + } + + "Value is not DER-encoded" shouldBe exception.message + } + + @Test + fun `Value should be refused if it's not a sequence`() { + val serialization = DERVisibleString("hey").encoded + + val exception = assertThrows { + ASN1Utils.deserializeHeterogeneousSequence(serialization) + } + + "Value is not an ASN.1 sequence" shouldBe exception.message + } + + @Test + fun `Explicitly tagged items should be deserialized with their corresponding types`() { + val serialization = ASN1Utils.serializeSequence(listOf(visibleString, visibleString)) + + val sequence = ASN1Utils.deserializeHomogeneousSequence(serialization) + + 2 shouldBe sequence.size + val value1Deserialized = sequence.first() + visibleString shouldBe value1Deserialized + val value2Deserialized = sequence.last() + visibleString shouldBe value2Deserialized + } + + @Test + fun `Explicitly tagged items with unexpected types should be refused`() { + val serialization = ASN1Utils.serializeSequence(listOf(visibleString, octetString)) + + val exception = assertThrows { + ASN1Utils.deserializeHomogeneousSequence(serialization) + } + + exception.message shouldBe + "Sequence contains an item of an unexpected type " + + "(${octetString::class.java.simpleName})" + } + + @Test + fun `Implicitly tagged items should be deserialized with their corresponding types`() { + val serialization = + ASN1Utils.serializeSequence(listOf(visibleString, octetString), false) + + val sequence = ASN1Utils.deserializeHeterogeneousSequence(serialization) + + 2 shouldBe sequence.size + visibleString.octets shouldBe + ASN1Utils.getVisibleString(sequence.first()).octets + octetString.octets shouldBe ASN1Utils.getOctetString(sequence[1]).octets + } + } +}