From 5b974be51e7e982b79034c024377fcb6924c7291 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Fri, 15 Sep 2023 18:24:07 +0100 Subject: [PATCH] Implement account request message serialisation --- app/build.gradle | 4 +- .../letro/server/messages/AccountRequest.kt | 43 ++++ .../letro/utils/crypto/RSASigning.kt | 30 +++ .../relaycorp/letro/utils/crypto/Utils.kt | 7 + .../server/messages/AccountRequestTest.kt | 212 ++++++++++++++++++ .../relaycorp/letro/testing/crypto/Keys.kt | 10 + .../letro/utils/crypto/RSASigningTest.kt | 63 ++++++ 7 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/crypto/RSASigning.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/crypto/Utils.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/testing/crypto/Keys.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/utils/crypto/RSASigningTest.kt diff --git a/app/build.gradle b/app/build.gradle index 1b26174e..45073b9a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,7 +78,9 @@ dependencies { implementation 'com.github.relaycorp:awala-endpoint-android:1.13.22' // Letro messaging - implementation("org.bouncycastle:bcprov-jdk15on:1.70") + def bouncy_castle_version = "1.70" + implementation("org.bouncycastle:bcprov-jdk15on:$bouncy_castle_version") + implementation("org.bouncycastle:bcpkix-jdk15on:$bouncy_castle_version") // Compose implementation platform('androidx.compose:compose-bom:2023.06.01') diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt new file mode 100644 index 00000000..6bc3bdce --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt @@ -0,0 +1,43 @@ +package tech.relaycorp.letro.server.messages + +import org.bouncycastle.asn1.DERBitString +import org.bouncycastle.asn1.DERUTF8String +import org.bouncycastle.asn1.DERVisibleString +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import tech.relaycorp.letro.utils.asn1.ASN1Utils +import tech.relaycorp.letro.utils.crypto.RSASigning +import java.security.PrivateKey +import java.security.PublicKey +import java.util.Locale + +class AccountRequest( + val userName: String, + val locale: Locale, + val veraidMemberPublicKey: PublicKey, +) { + fun serialise(veraidMemberPrivateKey: PrivateKey): ByteArray { + val requestEncoded = ASN1Utils.makeSequence( + listOf( + DERUTF8String(userName), + serialiseLocale(), + SubjectPublicKeyInfo.getInstance(veraidMemberPublicKey.encoded), + ), + explicitTagging = false, + ) + val signature = RSASigning.sign(requestEncoded.encoded, veraidMemberPrivateKey) + return ASN1Utils.serializeSequence(listOf(requestEncoded, DERBitString(signature)), false) + } + + private fun serialiseLocale(): DERVisibleString { + val languageCode = locale.language.lowercase() + val countryCode = locale.country.lowercase() + val localeNormalised = if (languageCode.isEmpty()) { + "" + } else if (countryCode.isEmpty()) { + languageCode + } else { + "$languageCode-$countryCode" + } + return DERVisibleString(localeNormalised) + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/utils/crypto/RSASigning.kt b/app/src/main/java/tech/relaycorp/letro/utils/crypto/RSASigning.kt new file mode 100644 index 00000000..b4734c6c --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/crypto/RSASigning.kt @@ -0,0 +1,30 @@ +package tech.relaycorp.letro.utils.crypto + +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature + +/** + * Plain RSA signatures (without PKCS#7/CMS SignedData). + */ +internal object RSASigning { + fun sign(plaintext: ByteArray, privateKey: PrivateKey): ByteArray { + val signature = makeSignature() + signature.initSign(privateKey) + signature.update(plaintext) + return signature.sign() + } + + fun verify( + signatureSerialisation: ByteArray, + publicKey: PublicKey, + expectedPlaintext: ByteArray, + ): Boolean { + val signature = makeSignature() + signature.initVerify(publicKey) + signature.update(expectedPlaintext) + return signature.verify(signatureSerialisation) + } + + private fun makeSignature() = Signature.getInstance("SHA256withRSAandMGF1", BC_PROVIDER) +} diff --git a/app/src/main/java/tech/relaycorp/letro/utils/crypto/Utils.kt b/app/src/main/java/tech/relaycorp/letro/utils/crypto/Utils.kt new file mode 100644 index 00000000..003a5c82 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/crypto/Utils.kt @@ -0,0 +1,7 @@ +@file:JvmName("Utils") + +package tech.relaycorp.letro.utils.crypto + +import org.bouncycastle.jce.provider.BouncyCastleProvider + +internal val BC_PROVIDER = BouncyCastleProvider() diff --git a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt new file mode 100644 index 00000000..e3dd4689 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt @@ -0,0 +1,212 @@ +package tech.relaycorp.letro.server.messages + +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.beUpperCase +import io.kotest.matchers.string.match +import org.bouncycastle.asn1.ASN1BitString +import org.bouncycastle.asn1.ASN1Sequence +import org.bouncycastle.asn1.ASN1TaggedObject +import org.bouncycastle.asn1.ASN1UTF8String +import org.bouncycastle.asn1.ASN1VisibleString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair +import tech.relaycorp.letro.utils.asn1.ASN1Utils +import tech.relaycorp.letro.utils.crypto.RSASigning +import java.util.Locale + +const val USER_NAME = "alice" +val LOCALE = Locale("EN", "GB") + +val keyPair = generateRSAKeyPair() + +class AccountRequestTest { + @Nested + inner class Serialize { + @Nested + inner class RequestSerialisation { + @Nested + inner class UserName { + @Test + fun `Should serialise the user name as UTF8String`() { + val request = AccountRequest( + userName = USER_NAME, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val requestSequence = parseRequestSequence(serialisation) + val userNameEncoded = ASN1UTF8String.getInstance( + requestSequence.getObjectAt(0) as ASN1TaggedObject, + false, + ) + userNameEncoded.string shouldBe USER_NAME + } + + @Test + fun `Should support non-ASCII characters`() { + val userName = "👩‍💻" + val request = AccountRequest( + userName = userName, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val requestSequence = parseRequestSequence(serialisation) + val userNameEncoded = ASN1UTF8String.getInstance( + requestSequence.getObjectAt(0) as ASN1TaggedObject, + false, + ) + userNameEncoded.string shouldBe userName + } + } + + @Nested + inner class LocaleSerialisation { + @Test + fun `Should serialise the language as a string`() { + val request = AccountRequest( + userName = USER_NAME, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val locale = parseLocaleSequence(serialisation) + locale.string.split("-")[0] shouldBe LOCALE.language.lowercase() + } + + @Test + fun `Should serialise the country as a lowercase string`() { + val request = AccountRequest( + userName = USER_NAME, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + request.locale.country should beUpperCase() + + val serialisation = request.serialise(keyPair.private) + + val locale = parseLocaleSequence(serialisation) + val countryCode = locale.string.split("-")[1] + countryCode shouldBe LOCALE.country.lowercase() + } + + @Test + fun `Should result in empty string if language is missing`() { + val request = AccountRequest( + userName = USER_NAME, + locale = Locale("", LOCALE.country), + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val locale = parseLocaleSequence(serialisation) + locale.string shouldBe "" + } + + @Test + fun `Should only serialise the language if country is missing`() { + val request = AccountRequest( + userName = USER_NAME, + locale = Locale(LOCALE.language, ""), + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val locale = parseLocaleSequence(serialisation) + locale.string shouldBe LOCALE.language + } + + @Test + fun `Should not serialise the variant`() { + val request = AccountRequest( + userName = USER_NAME, + locale = Locale(LOCALE.language, LOCALE.country, "Oxford"), + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val locale = parseLocaleSequence(serialisation) + locale.string should match(Regex("[a-z]{2}-[a-z]{2}")) + } + + private fun parseLocaleSequence(serialisation: ByteArray): ASN1VisibleString { + val requestSequence = parseRequestSequence(serialisation) + return ASN1Utils.getVisibleString(requestSequence.getObjectAt(1) as ASN1TaggedObject) + } + } + + @Test + fun `Should serialize the public key`() { + val request = AccountRequest( + userName = USER_NAME, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val requestSequence = parseRequestSequence(serialisation) + val publicKeyEncoded = SubjectPublicKeyInfo.getInstance( + requestSequence.getObjectAt(2) as ASN1TaggedObject, + false, + ) + publicKeyEncoded.encoded shouldBe keyPair.public.encoded + } + + private fun parseRequestSequence(serialisation: ByteArray): ASN1Sequence { + val signatureSequence = ASN1Utils.deserializeHeterogeneousSequence(serialisation) + return DERSequence.getInstance(signatureSequence[0], false) + } + } + + @Nested + inner class Signature { + @Test + fun `Signature should be serialised as a BIT STRING`() { + val request = AccountRequest( + userName = USER_NAME, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val signatureSequence = ASN1Utils.deserializeHeterogeneousSequence(serialisation) + ASN1BitString.getInstance(signatureSequence[1], false) // Should not throw + } + + @Test + fun `Signature should be computed over the request`() { + val request = AccountRequest( + userName = USER_NAME, + locale = LOCALE, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + val signatureSequence = ASN1Utils.deserializeHeterogeneousSequence(serialisation) + val requestSequence = DERSequence.getInstance(signatureSequence[0], false) + val signatureEncoded = ASN1BitString.getInstance(signatureSequence[1], false) + RSASigning.verify( + signatureEncoded.bytes, + keyPair.public, + requestSequence.encoded, + ) shouldBe true + } + } + } +} diff --git a/app/src/test/java/tech/relaycorp/letro/testing/crypto/Keys.kt b/app/src/test/java/tech/relaycorp/letro/testing/crypto/Keys.kt new file mode 100644 index 00000000..ce5e0636 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/testing/crypto/Keys.kt @@ -0,0 +1,10 @@ +package tech.relaycorp.letro.testing.crypto + +import java.security.KeyPair +import java.security.KeyPairGenerator + +fun generateRSAKeyPair(): KeyPair { + val keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + return keyGen.generateKeyPair() +} diff --git a/app/src/test/java/tech/relaycorp/letro/utils/crypto/RSASigningTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/crypto/RSASigningTest.kt new file mode 100644 index 00000000..c76845b3 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/utils/crypto/RSASigningTest.kt @@ -0,0 +1,63 @@ +package tech.relaycorp.letro.utils.crypto + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair +import java.security.Signature +import java.security.spec.MGF1ParameterSpec +import java.security.spec.PSSParameterSpec + +class RSASigningTest { + private val plaintext = "the plaintext".toByteArray() + private val keyPair = generateRSAKeyPair() + + @Nested + inner class Sign { + @Test + fun `The plaintext should be signed with RSA-PSS, SHA-256 and MGF1`() { + val ciphertext = RSASigning.sign(plaintext, keyPair.private) + + val signature: Signature = makeSignature() + signature.initVerify(keyPair.public) + signature.update(plaintext) + + signature.verify(ciphertext) shouldBe true + } + } + + @Nested + inner class Verify { + @Test + fun `Invalid plaintexts should be refused`() { + val anotherPlaintext = byteArrayOf(*plaintext, 1) + val ciphertext = RSASigning.sign(anotherPlaintext, keyPair.private) + + RSASigning.verify(ciphertext, keyPair.public, plaintext) shouldBe false + } + + @Test + fun `Algorithms other than RSA-PSS with SHA-256 and MGF1 should be refused`() { + val signature: Signature = Signature.getInstance("SHA256withRSA", BC_PROVIDER) + signature.initSign(keyPair.private) + signature.update(plaintext) + val ciphertext = signature.sign() + + RSASigning.verify(ciphertext, keyPair.public, plaintext) shouldBe false + } + + @Test + fun `Valid signatures should be accepted`() { + val ciphertext = RSASigning.sign(plaintext, keyPair.private) + + RSASigning.verify(ciphertext, keyPair.public, plaintext) shouldBe true + } + } + + private fun makeSignature(): Signature { + val signature = Signature.getInstance("SHA256withRSA/PSS", BC_PROVIDER) + val pssParameterSpec = PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1) + signature.setParameter(pssParameterSpec) + return signature + } +}