From 055db87ac8def943fd35178e6e0d1ffd2e37bb40 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 12 Sep 2023 16:29:36 +0100 Subject: [PATCH 01/20] 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 + } + } +} From 4f1cfd54037a71329b105ae21620ce8915af25a1 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 12 Sep 2023 16:38:16 +0100 Subject: [PATCH 02/20] format --- .../java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index f9ee9e0c..f2d17ec7 100644 --- a/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt @@ -65,7 +65,7 @@ internal class ASN1UtilsTest { val item2 = sequence.readObject() item2 should beInstanceOf() octetString.octets shouldBe - ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets + ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets } @Test @@ -138,8 +138,8 @@ internal class ASN1UtilsTest { } exception.message shouldBe - "Sequence contains an item of an unexpected type " + - "(${octetString::class.java.simpleName})" + "Sequence contains an item of an unexpected type " + + "(${octetString::class.java.simpleName})" } @Test @@ -151,7 +151,7 @@ internal class ASN1UtilsTest { 2 shouldBe sequence.size visibleString.octets shouldBe - ASN1Utils.getVisibleString(sequence.first()).octets + ASN1Utils.getVisibleString(sequence.first()).octets octetString.octets shouldBe ASN1Utils.getOctetString(sequence[1]).octets } } From 36e0d202da19738d0d1575803fe455ef7d8679db Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 12 Sep 2023 16:45:34 +0100 Subject: [PATCH 03/20] format --- .../java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 0ee00f7e..b8255353 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt @@ -1,6 +1,5 @@ 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 @@ -11,11 +10,14 @@ import org.bouncycastle.asn1.ASN1VisibleString import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.DERSequence import org.bouncycastle.asn1.DERTaggedObject +import java.io.IOException 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 -> + val finalItems = if (explicitTagging) { + items + } else items.mapIndexed { index, item -> DERTaggedObject(false, index, item) } finalItems.forEach { messagesVector.add(it) } @@ -46,14 +48,14 @@ internal object ASN1Utils { @Throws(ASN1Exception::class) inline fun deserializeHomogeneousSequence( - serialization: ByteArray + 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})" + "(${it::class.java.simpleName})", ) } @Suppress("USELESS_CAST") From 5b974be51e7e982b79034c024377fcb6924c7291 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Fri, 15 Sep 2023 18:24:07 +0100 Subject: [PATCH 04/20] 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 + } +} From 5e8077584b9d95f19772f68bda3b782427d7ec23 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Fri, 15 Sep 2023 18:46:51 +0100 Subject: [PATCH 05/20] Replace connection params .der file so we can connect to letro.app --- .../main/res/raw/server_connection_params.der | Bin 432 -> 418 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/app/src/main/res/raw/server_connection_params.der b/app/src/main/res/raw/server_connection_params.der index 64c039483ef8f6fb0393b181f976088726fe18a5..97f63fb29c4b9b276b0823426069d13fdefb076a 100644 GIT binary patch delta 392 zcmV;30eAke1EK>DFoFS|fC+46baHPlVQ_Gf9VLIUr^NJ~QPM*T!$zr zMkd8?QNNUB-86*xQuKjj;&DpDXf!7e3ZgehUZ*FU4a&sxJ#z)S_ol?Ma3~tq zWgOKvAFKEf|{Tp?GF9n2U=y~z9ba&@~)8*}}GfDb@UV&$~ AH2?qr From a86eeab8a29e33ee66556c3162de765c3bd37ef9 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 12 Sep 2023 16:29:36 +0100 Subject: [PATCH 06/20] integrate ASN.1 --- .../java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt | 10 ++++------ .../tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) 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 index b8255353..0ee00f7e 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt @@ -1,5 +1,6 @@ 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 @@ -10,14 +11,11 @@ import org.bouncycastle.asn1.ASN1VisibleString import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.DERSequence import org.bouncycastle.asn1.DERTaggedObject -import java.io.IOException 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 -> + val finalItems = if (explicitTagging) items else items.mapIndexed { index, item -> DERTaggedObject(false, index, item) } finalItems.forEach { messagesVector.add(it) } @@ -48,14 +46,14 @@ internal object ASN1Utils { @Throws(ASN1Exception::class) inline fun deserializeHomogeneousSequence( - serialization: ByteArray, + 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})", + "(${it::class.java.simpleName})" ) } @Suppress("USELESS_CAST") 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 index f2d17ec7..f9ee9e0c 100644 --- a/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt @@ -65,7 +65,7 @@ internal class ASN1UtilsTest { val item2 = sequence.readObject() item2 should beInstanceOf() octetString.octets shouldBe - ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets + ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets } @Test @@ -138,8 +138,8 @@ internal class ASN1UtilsTest { } exception.message shouldBe - "Sequence contains an item of an unexpected type " + - "(${octetString::class.java.simpleName})" + "Sequence contains an item of an unexpected type " + + "(${octetString::class.java.simpleName})" } @Test @@ -151,7 +151,7 @@ internal class ASN1UtilsTest { 2 shouldBe sequence.size visibleString.octets shouldBe - ASN1Utils.getVisibleString(sequence.first()).octets + ASN1Utils.getVisibleString(sequence.first()).octets octetString.octets shouldBe ASN1Utils.getOctetString(sequence[1]).octets } } From 1dc48b5793f2de4b618e40eaf21cb11dbaf9f088 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Sat, 16 Sep 2023 14:09:25 +0100 Subject: [PATCH 07/20] make signature compatible with nodejs --- .../letro/server/messages/AccountRequest.kt | 8 +- .../letro/utils/crypto/KeyEncoding.kt | 41 ++++++++ .../server/messages/AccountRequestTest.kt | 3 +- .../letro/utils/crypto/KeyEncodingTest.kt | 93 +++++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt 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 index 6bc3bdce..5fc8b958 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt @@ -3,9 +3,9 @@ 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 tech.relaycorp.letro.utils.crypto.spkiEncode import java.security.PrivateKey import java.security.PublicKey import java.util.Locale @@ -19,8 +19,8 @@ class AccountRequest( val requestEncoded = ASN1Utils.makeSequence( listOf( DERUTF8String(userName), - serialiseLocale(), - SubjectPublicKeyInfo.getInstance(veraidMemberPublicKey.encoded), + encodeLocale(), + veraidMemberPublicKey.spkiEncode(), ), explicitTagging = false, ) @@ -28,7 +28,7 @@ class AccountRequest( return ASN1Utils.serializeSequence(listOf(requestEncoded, DERBitString(signature)), false) } - private fun serialiseLocale(): DERVisibleString { + private fun encodeLocale(): DERVisibleString { val languageCode = locale.language.lowercase() val countryCode = locale.country.lowercase() val localeNormalised = if (languageCode.isEmpty()) { diff --git a/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt b/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt new file mode 100644 index 00000000..9e3f86ed --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt @@ -0,0 +1,41 @@ +package tech.relaycorp.letro.utils.crypto + +import org.bouncycastle.asn1.ASN1BitString +import org.bouncycastle.asn1.ASN1Integer +import org.bouncycastle.asn1.DERNull +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers +import org.bouncycastle.asn1.pkcs.RSASSAPSSparams +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import java.security.PublicKey + +private val rsaPssSha256Mgf1Algorithm = AlgorithmIdentifier( + PKCSObjectIdentifiers.id_RSASSA_PSS, + RSASSAPSSparams( + AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256, DERNull.INSTANCE), + AlgorithmIdentifier( + PKCSObjectIdentifiers.id_mgf1, + AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256), + ), + ASN1Integer(32), + ASN1Integer(1), + ), +) + +/** + * Encode the public key as a SubjectPublicKeyInfo structure with the RSA-PSS algorithm. + * + * Otherwise it'd be encoded as an RSA key (no padding specified), which isn't supported by + * the server (which uses the Node.js crypto module). + */ +fun PublicKey.spkiEncode(): SubjectPublicKeyInfo { + if (algorithm != "RSA") { + throw IllegalArgumentException("Only RSA keys are supported") + } + + val keyWrapperEncoded = DERSequence.getInstance(encoded) + val keyEncoded = ASN1BitString.getInstance(keyWrapperEncoded.getObjectAt(1)) + return SubjectPublicKeyInfo(rsaPssSha256Mgf1Algorithm, keyEncoded.bytes) +} 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 index e3dd4689..18cdb9bc 100644 --- a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt @@ -16,6 +16,7 @@ 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 tech.relaycorp.letro.utils.crypto.spkiEncode import java.util.Locale const val USER_NAME = "alice" @@ -163,7 +164,7 @@ class AccountRequestTest { requestSequence.getObjectAt(2) as ASN1TaggedObject, false, ) - publicKeyEncoded.encoded shouldBe keyPair.public.encoded + publicKeyEncoded shouldBe keyPair.public.spkiEncode() } private fun parseRequestSequence(serialisation: ByteArray): ASN1Sequence { diff --git a/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt new file mode 100644 index 00000000..16b77e11 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt @@ -0,0 +1,93 @@ +package tech.relaycorp.letro.utils.crypto + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.bouncycastle.asn1.ASN1BitString +import org.bouncycastle.asn1.DERNull +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers +import org.bouncycastle.asn1.pkcs.RSASSAPSSparams +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair +import java.security.KeyPairGenerator +import java.security.PublicKey + +class KeyEncodingTest { + @Nested + inner class PublicKeySpkiEncoding { + val rsaPublicKey: PublicKey = generateRSAKeyPair().public + + @Test + fun `Non-RSA keys should be refused`() { + val keyGen = KeyPairGenerator.getInstance("DSA", BC_PROVIDER) + keyGen.initialize(1024) + val keyPair = keyGen.generateKeyPair() + + val exception = shouldThrow { + keyPair.public.spkiEncode() + } + + exception.message shouldBe "Only RSA keys are supported" + } + + @Nested + inner class Algorithm { + @Test + fun `Algorithm should be RSA-PSS`() { + val encoding = rsaPublicKey.spkiEncode() + + encoding.algorithm.algorithm shouldBe PKCSObjectIdentifiers.id_RSASSA_PSS + } + + @Nested + inner class Params { + @Test + fun `Hash should be SHA-256`() { + val encoding = rsaPublicKey.spkiEncode() + + val parameters = encoding.algorithm.parameters as RSASSAPSSparams + parameters.hashAlgorithm.algorithm shouldBe NISTObjectIdentifiers.id_sha256 + parameters.hashAlgorithm.parameters shouldBe DERNull.INSTANCE + } + + @Test + fun `MGF should be MGF1 with SHA-256`() { + val encoding = rsaPublicKey.spkiEncode() + + val parameters = encoding.algorithm.parameters as RSASSAPSSparams + parameters.maskGenAlgorithm.algorithm shouldBe PKCSObjectIdentifiers.id_mgf1 + val mgfAlgorithmParams = parameters.maskGenAlgorithm.parameters + mgfAlgorithmParams shouldBe AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256) + } + + @Test + fun `Salt length should be 32`() { + val encoding = rsaPublicKey.spkiEncode() + + val parameters = encoding.algorithm.parameters as RSASSAPSSparams + parameters.saltLength.intValueExact() shouldBe 32 + } + + @Test + fun `Trailer field should be 1`() { + val encoding = rsaPublicKey.spkiEncode() + + val parameters = encoding.algorithm.parameters as RSASSAPSSparams + parameters.trailerField.intValueExact() shouldBe 1 + } + } + } + + @Test + fun `Key should be just the key without the algorithm`() { + val encoding = rsaPublicKey.spkiEncode() + + val keyWrapperEncoded = DERSequence.getInstance(rsaPublicKey.encoded) + val keyEncoded = ASN1BitString.getInstance(keyWrapperEncoded.getObjectAt(1)) + encoding.publicKeyData shouldBe keyEncoded + } + } +} From 39b89273afe2f3b69ed7f6219ab0606a773158f0 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Sat, 16 Sep 2023 14:11:42 +0100 Subject: [PATCH 08/20] fix merge --- .../java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt | 10 ++++++---- .../tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) 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 index 0ee00f7e..b8255353 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt @@ -1,6 +1,5 @@ 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 @@ -11,11 +10,14 @@ import org.bouncycastle.asn1.ASN1VisibleString import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.DERSequence import org.bouncycastle.asn1.DERTaggedObject +import java.io.IOException 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 -> + val finalItems = if (explicitTagging) { + items + } else items.mapIndexed { index, item -> DERTaggedObject(false, index, item) } finalItems.forEach { messagesVector.add(it) } @@ -46,14 +48,14 @@ internal object ASN1Utils { @Throws(ASN1Exception::class) inline fun deserializeHomogeneousSequence( - serialization: ByteArray + 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})" + "(${it::class.java.simpleName})", ) } @Suppress("USELESS_CAST") 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 index f9ee9e0c..f2d17ec7 100644 --- a/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/utils/asn1/ASN1UtilsTest.kt @@ -65,7 +65,7 @@ internal class ASN1UtilsTest { val item2 = sequence.readObject() item2 should beInstanceOf() octetString.octets shouldBe - ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets + ((item2 as DEROctetStringParser).loadedObject as DEROctetString).octets } @Test @@ -138,8 +138,8 @@ internal class ASN1UtilsTest { } exception.message shouldBe - "Sequence contains an item of an unexpected type " + - "(${octetString::class.java.simpleName})" + "Sequence contains an item of an unexpected type " + + "(${octetString::class.java.simpleName})" } @Test @@ -151,7 +151,7 @@ internal class ASN1UtilsTest { 2 shouldBe sequence.size visibleString.octets shouldBe - ASN1Utils.getVisibleString(sequence.first()).octets + ASN1Utils.getVisibleString(sequence.first()).octets octetString.octets shouldBe ASN1Utils.getOctetString(sequence[1]).octets } } From 3725bc51c2eb9a623a1c1356b5a23615c3440fc8 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 18 Sep 2023 15:49:49 +0100 Subject: [PATCH 09/20] Implement account creation message deserialisation --- .../letro/server/messages/AccountCreation.kt | 93 ++++++++ .../letro/server/messages/AccountRequest.kt | 5 + .../MalformedAccountCreationException.kt | 4 + .../tech/relaycorp/letro/utils/i18n/Locale.kt | 23 ++ .../server/messages/AccountCreationTest.kt | 212 ++++++++++++++++++ .../server/messages/AccountRequestTest.kt | 120 +++------- .../relaycorp/letro/utils/i18n/LocaleTest.kt | 102 +++++++++ 7 files changed, 465 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt new file mode 100644 index 00000000..05c72719 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt @@ -0,0 +1,93 @@ +package tech.relaycorp.letro.server.messages + +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.ASN1TaggedObject +import org.bouncycastle.asn1.ASN1UTF8String +import tech.relaycorp.letro.utils.asn1.ASN1Exception +import tech.relaycorp.letro.utils.asn1.ASN1Utils +import tech.relaycorp.letro.utils.i18n.parseLocale +import java.util.Locale + +/** + * This message signifies that a VeraId identifier has been created. + * + * See: https://docs.relaycorp.tech/letro-server/account-creation#account-creation-1 + */ +class AccountCreation( + val requestedUserName: String, + val locale: Locale, + val assignedUserId: String, + val veraidBundle: ByteArray, +) { + companion object { + fun deserialise(serialised: ByteArray): AccountCreation { + val accountCreationSequence = try { + ASN1Utils.deserializeSequence(serialised) + } catch (exc: ASN1Exception) { + throw MalformedAccountCreationException( + "AccountCreation should be a DER-encoded sequence", + exc, + ) + } + if (accountCreationSequence.size() < 4) { + throw MalformedAccountCreationException( + "AccountCreation SEQUENCE should have at least 4 items", + ) + } + + val requestedUserName = decodeRequestedUserName(accountCreationSequence.getObjectAt(0)) + val locale = decodeLocale(accountCreationSequence.getObjectAt(1)) + val assignedUserId = decodeAssignedUserId(accountCreationSequence.getObjectAt(2)) + val veraidBundle = decodeVeraidBundle(accountCreationSequence.getObjectAt(3)) + return AccountCreation(requestedUserName, locale, assignedUserId, veraidBundle) + } + + private fun decodeRequestedUserName(userNameTagged: ASN1Encodable): String { + val requestedUserNameEncoded = try { + ASN1UTF8String.getInstance(userNameTagged as ASN1TaggedObject, false) + } catch (exc: RuntimeException) { + throw MalformedAccountCreationException( + "AccountCreation requestedUserName should be a DER-encoded UTF8String", + exc, + ) + } + return requestedUserNameEncoded.string + } + + private fun decodeLocale(localeTagged: ASN1Encodable): Locale { + val localeEncoded = try { + ASN1Utils.getVisibleString(localeTagged as ASN1TaggedObject) + } catch (exc: RuntimeException) { + throw MalformedAccountCreationException( + "AccountCreation locale should be a DER-encoded VisibleString", + exc, + ) + } + return localeEncoded.string.parseLocale() + } + + private fun decodeAssignedUserId(assignedUserIdTagged: ASN1Encodable): String { + val assignedUserIdEncoded = try { + ASN1UTF8String.getInstance(assignedUserIdTagged as ASN1TaggedObject, false) + } catch (exc: RuntimeException) { + throw MalformedAccountCreationException( + "AccountCreation assignedUserId should be a DER-encoded UTF8String", + exc, + ) + } + return assignedUserIdEncoded.string + } + + private fun decodeVeraidBundle(veraidBundleTagged: ASN1Encodable): ByteArray { + val veraidBundleEncoded = try { + ASN1Utils.getOctetString(veraidBundleTagged as ASN1TaggedObject) + } catch (exc: RuntimeException) { + throw MalformedAccountCreationException( + "AccountCreation veraidBundle should be a DER-encoded OCTET STRING", + exc, + ) + } + return veraidBundleEncoded.octets + } + } +} 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 index 5fc8b958..4692c09c 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt @@ -10,6 +10,11 @@ import java.security.PrivateKey import java.security.PublicKey import java.util.Locale +/** + * This message signifies a Letro user's intention to create a VeraId identifier. + * + * See https://docs.relaycorp.tech/letro-server/account-creation#account-creation-request + */ class AccountRequest( val userName: String, val locale: Locale, diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt new file mode 100644 index 00000000..82c5abd4 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt @@ -0,0 +1,4 @@ +package tech.relaycorp.letro.server.messages + +class MalformedAccountCreationException(message: String?, cause: Throwable? = null) : + Exception(message, cause) diff --git a/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt b/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt new file mode 100644 index 00000000..6e7f2ec7 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt @@ -0,0 +1,23 @@ +package tech.relaycorp.letro.utils.i18n + +import java.util.Locale + +fun Locale.normaliseString(): String { + val languageCode = language.lowercase() + val countryCode = country.lowercase() + return if (languageCode.isEmpty()) { + "" + } else if (countryCode.isEmpty()) { + languageCode + } else { + "$languageCode-$countryCode" + } +} + +fun String.parseLocale(): Locale { + val localeParts = split("-") + val languageCode = localeParts[0].lowercase() + val countryCode = localeParts.getOrNull(1)?.uppercase() ?: "" + val variantCode = localeParts.getOrNull(2) ?: "" + return Locale(languageCode, countryCode, variantCode) +} diff --git a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt new file mode 100644 index 00000000..207b04a7 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt @@ -0,0 +1,212 @@ +package tech.relaycorp.letro.server.messages + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf +import org.bouncycastle.asn1.DERNull +import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERTaggedObject +import org.bouncycastle.asn1.DERUTF8String +import org.bouncycastle.asn1.DERVisibleString +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import tech.relaycorp.letro.utils.asn1.ASN1Exception +import tech.relaycorp.letro.utils.asn1.ASN1Utils +import tech.relaycorp.letro.utils.i18n.normaliseString +import java.util.Locale + +class AccountCreationTest { + val requestedUserName = "alice" + val locale = Locale("EN", "GB") + val assignedUserId = "alice@example.com" + val veraidBundle = byteArrayOf(0x00) + + val nonAsciiUsername = "δΉ…ηΎŽε­" + val nonAsciiDomainName = "γ―γ˜γ‚γ‚ˆγ†.みんγͺ" + + @Nested + inner class Deserialise { + @Test + fun `Serialisation should be a DER-encoded sequence`() { + val exception = shouldThrow { + AccountCreation.deserialise(byteArrayOf(0x00)) + } + + exception.message shouldBe "AccountCreation should be a DER-encoded sequence" + exception.cause should beInstanceOf() + } + + @Test + fun `Sequence should have at least 4 items`() { + val malformedSerialisation = ASN1Utils.serializeSequence( + listOf(DERNull.INSTANCE, DERNull.INSTANCE, DERNull.INSTANCE), + false, + ) + + val exception = shouldThrow { + AccountCreation.deserialise(malformedSerialisation) + } + + exception.message shouldBe "AccountCreation SEQUENCE should have at least 4 items" + } + + @Nested + inner class RequestedUserName { + @Test + fun `Should be a DER-encoded UTF8String`() { + val malformedSerialisation = DERSequence( + arrayOf( + DERTaggedObject(true, 0, DERNull.INSTANCE), + DERNull.INSTANCE, + DERNull.INSTANCE, + DERNull.INSTANCE, + ), + ).encoded + + val exception = shouldThrow { + AccountCreation.deserialise(malformedSerialisation) + } + + exception.message shouldBe + "AccountCreation requestedUserName should be a DER-encoded UTF8String" + } + + @Test + fun `Should be decoded as a UTF-8 string`() { + val serialisation = AccountCreation( + nonAsciiUsername, + locale, + assignedUserId, + veraidBundle, + ).serialise() + + val accountCreation = AccountCreation.deserialise(serialisation) + + accountCreation.requestedUserName shouldBe nonAsciiUsername + } + } + + @Nested + inner class Locale { + @Test + fun `Should be a DER-encoded VisibleString`() { + val malformedSerialisation = DERSequence( + arrayOf( + DERTaggedObject(false, 0, DERUTF8String(requestedUserName)), + DERTaggedObject(true, 0, DERVisibleString(locale.normaliseString())), + DERUTF8String(assignedUserId), + DEROctetString(veraidBundle), + ), + ).encoded + + val exception = shouldThrow { + AccountCreation.deserialise(malformedSerialisation) + } + + exception.message shouldBe + "AccountCreation locale should be a DER-encoded VisibleString" + } + + @Test + fun `Should be decoded as a Locale`() { + val serialisation = AccountCreation( + requestedUserName, + locale, + assignedUserId, + veraidBundle, + ).serialise() + + val accountCreation = AccountCreation.deserialise(serialisation) + + accountCreation.locale shouldBe locale + } + } + + @Nested + inner class AssignedUserId { + @Test + fun `Should be a DER-encoded UTF8String`() { + val malformedSerialisation = DERSequence( + arrayOf( + DERTaggedObject(false, 0, DERUTF8String(requestedUserName)), + DERTaggedObject(false, 1, DERVisibleString(locale.normaliseString())), + DERTaggedObject(true, 2, DERVisibleString(assignedUserId)), + DERNull.INSTANCE, + ), + ).encoded + + val exception = shouldThrow { + AccountCreation.deserialise(malformedSerialisation) + } + + exception.message shouldBe + "AccountCreation assignedUserId should be a DER-encoded UTF8String" + } + + @Test + fun `Should be decoded as a UTF-8 string`() { + val nonAsciiAssignedUserId = "$nonAsciiUsername@$nonAsciiDomainName" + val serialisation = AccountCreation( + requestedUserName, + locale, + nonAsciiAssignedUserId, + veraidBundle, + ).serialise() + + val accountCreation = AccountCreation.deserialise(serialisation) + + accountCreation.assignedUserId shouldBe nonAsciiAssignedUserId + } + } + + @Nested + inner class VeraidBundle { + @Test + fun `Should be a DER-encoded OCTET STRING`() { + val malformedSerialisation = DERSequence( + arrayOf( + DERTaggedObject(false, 0, DERUTF8String(requestedUserName)), + DERTaggedObject(false, 1, DERVisibleString(locale.normaliseString())), + DERTaggedObject(false, 2, DERVisibleString(assignedUserId)), + DERTaggedObject(true, 3, DERNull.INSTANCE), + ), + ).encoded + + val exception = shouldThrow { + AccountCreation.deserialise(malformedSerialisation) + } + + exception.message shouldBe + "AccountCreation veraidBundle should be a DER-encoded OCTET STRING" + } + + @Test + fun `Should be decoded as a DER-encoded OCTET STRING`() { + val serialisation = AccountCreation( + requestedUserName, + locale, + assignedUserId, + veraidBundle, + ).serialise() + + val accountCreation = AccountCreation.deserialise(serialisation) + + accountCreation.veraidBundle shouldBe veraidBundle + } + } + } + + private fun AccountCreation.serialise(): ByteArray { + return ASN1Utils.serializeSequence( + listOf( + DERUTF8String(requestedUserName), + DERVisibleString(locale.normaliseString()), + DERUTF8String(assignedUserId), + DEROctetString(veraidBundle), + ), + false, + ) + } +} 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 index 18cdb9bc..5d6fccaf 100644 --- a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt @@ -1,14 +1,10 @@ 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 @@ -17,14 +13,15 @@ import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair import tech.relaycorp.letro.utils.asn1.ASN1Utils import tech.relaycorp.letro.utils.crypto.RSASigning import tech.relaycorp.letro.utils.crypto.spkiEncode +import tech.relaycorp.letro.utils.i18n.normaliseString import java.util.Locale -const val USER_NAME = "alice" -val LOCALE = Locale("EN", "GB") +class AccountRequestTest { + val userName = "alice" + val locale = Locale("EN", "GB") -val keyPair = generateRSAKeyPair() + val keyPair = generateRSAKeyPair() -class AccountRequestTest { @Nested inner class Serialize { @Nested @@ -34,8 +31,8 @@ class AccountRequestTest { @Test fun `Should serialise the user name as UTF8String`() { val request = AccountRequest( - userName = USER_NAME, - locale = LOCALE, + userName = userName, + locale = locale, veraidMemberPublicKey = keyPair.public, ) @@ -46,7 +43,7 @@ class AccountRequestTest { requestSequence.getObjectAt(0) as ASN1TaggedObject, false, ) - userNameEncoded.string shouldBe USER_NAME + userNameEncoded.string shouldBe userName } @Test @@ -54,7 +51,7 @@ class AccountRequestTest { val userName = "πŸ‘©β€πŸ’»" val request = AccountRequest( userName = userName, - locale = LOCALE, + locale = locale, veraidMemberPublicKey = keyPair.public, ) @@ -69,91 +66,26 @@ class AccountRequestTest { } } - @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) + @Test + fun `Should serialise the locale as a VisibleString`() { + val request = AccountRequest( + userName = userName, + locale = locale, + veraidMemberPublicKey = keyPair.public, + ) - val locale = parseLocaleSequence(serialisation) - locale.string should match(Regex("[a-z]{2}-[a-z]{2}")) - } + val serialisation = request.serialise(keyPair.private) - private fun parseLocaleSequence(serialisation: ByteArray): ASN1VisibleString { - val requestSequence = parseRequestSequence(serialisation) - return ASN1Utils.getVisibleString(requestSequence.getObjectAt(1) as ASN1TaggedObject) - } + val requestSequence = parseRequestSequence(serialisation) + val localeEncoded = ASN1Utils.getVisibleString(requestSequence.getObjectAt(1) as ASN1TaggedObject) + localeEncoded.string shouldBe locale.normaliseString() } @Test fun `Should serialize the public key`() { val request = AccountRequest( - userName = USER_NAME, - locale = LOCALE, + userName = userName, + locale = locale, veraidMemberPublicKey = keyPair.public, ) @@ -178,8 +110,8 @@ class AccountRequestTest { @Test fun `Signature should be serialised as a BIT STRING`() { val request = AccountRequest( - userName = USER_NAME, - locale = LOCALE, + userName = userName, + locale = locale, veraidMemberPublicKey = keyPair.public, ) @@ -192,8 +124,8 @@ class AccountRequestTest { @Test fun `Signature should be computed over the request`() { val request = AccountRequest( - userName = USER_NAME, - locale = LOCALE, + userName = userName, + locale = locale, veraidMemberPublicKey = keyPair.public, ) diff --git a/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt new file mode 100644 index 00000000..f12cd8b8 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt @@ -0,0 +1,102 @@ +package tech.relaycorp.letro.utils.i18n + +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.beUpperCase +import io.kotest.matchers.string.match +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.util.Locale + +val LOCALE = Locale("EN", "GB") + +class LocaleTest { + @Nested + inner class NormaliseString { + @Test + fun `Should lower case the country code`() { + LOCALE.country should beUpperCase() + + val localeString = LOCALE.normaliseString() + + val countryCode = localeString.split("-")[1] + countryCode shouldBe LOCALE.country.lowercase() + } + + @Test + fun `Should result in empty string if language code is missing`() { + val locale = Locale("", LOCALE.country) + + val localeString = locale.normaliseString() + + localeString shouldBe "" + } + + @Test + fun `Should only return the language code if country code is missing`() { + val locale = Locale(LOCALE.language) + + val localeString = locale.normaliseString() + + localeString shouldBe locale.language + } + + @Test + fun `Should not serialise the variant`() { + val locale = Locale(LOCALE.language, LOCALE.country, "Oxford") + + val localeString = locale.normaliseString() + + localeString should match(Regex("[a-z]{2}-[a-z]{2}")) + } + } + + @Nested + inner class ParseLocale { + val localeString = LOCALE.normaliseString() + + @Nested + inner class LanguageCode { + @Test + fun `Language should be decoded`() { + localeString.parseLocale().language shouldBe LOCALE.language + } + + @Test + fun `Language should be lower cased for consistency with Android`() { + "EN-gb".parseLocale().language shouldBe "en" + } + } + + @Nested + inner class CountryCode { + @Test + fun `Should be decoded if present`() { + localeString.parseLocale().country shouldBe LOCALE.country + } + + @Test + fun `Should be upper cased for consistency with Android`() { + "en-gb".parseLocale().country shouldBe "GB" + } + + @Test + fun `Should be absent if not present in string`() { + "en".parseLocale().country shouldBe "" + } + } + + @Nested + inner class VariantCode { + @Test + fun `Should be decoded if present`() { + "en-gb-oxford".parseLocale().variant shouldBe "oxford" + } + + @Test + fun `Should be absent if not present in string`() { + "en-gb".parseLocale().variant shouldBe "" + } + } + } +} From 1c812905d5ea62f3992149c2f097aed8a74a4511 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 19 Sep 2023 15:09:02 +0100 Subject: [PATCH 10/20] Implement `AccountCreation.validate()` --- app/build.gradle | 14 +- .../letro/server/messages/AccountCreation.kt | 68 ++++++- .../InvalidAccountCreationException.kt | 4 + .../MalformedAccountCreationException.kt | 4 - .../tech/relaycorp/letro/utils/LetroOids.kt | 7 + .../server/messages/AccountCreationTest.kt | 179 ++++++++++++++++-- .../server/messages/AccountRequestTest.kt | 10 +- .../relaycorp/letro/testing/veraid/Stubs.kt | 9 + 8 files changed, 265 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/server/messages/InvalidAccountCreationException.kt delete mode 100644 app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt create mode 100644 app/src/main/java/tech/relaycorp/letro/utils/LetroOids.kt create mode 100644 app/src/test/java/tech/relaycorp/letro/testing/veraid/Stubs.kt diff --git a/app/build.gradle b/app/build.gradle index 99983cb9..7b9cc988 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,13 +70,14 @@ dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - // Awala + // Awala-powered messaging implementation 'com.github.relaycorp:awala-endpoint-android:1.13.22' - - // Letro messaging def bouncy_castle_version = "1.70" - implementation("org.bouncycastle:bcprov-jdk15on:$bouncy_castle_version") - implementation("org.bouncycastle:bcpkix-jdk15on:$bouncy_castle_version") + implementation "org.bouncycastle:bcprov-jdk15on:$bouncy_castle_version" + implementation "org.bouncycastle:bcpkix-jdk15on:$bouncy_castle_version" + + // VeraId + implementation 'tech.relaycorp:veraid:1.10.0' // Compose implementation platform('androidx.compose:compose-bom:2023.09.00') @@ -107,6 +108,7 @@ dependencies { // Testing def junitVersion = "5.8.2" + def kotlinCoroutinesVersion = "1.7.3" testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" androidTestImplementation 'androidx.test.ext:junit:1.1.5' @@ -116,6 +118,8 @@ dependencies { debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' testImplementation 'io.kotest:kotest-assertions-core:5.7.2' + testImplementation "io.mockk:mockk:1.13.7" + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutinesVersion") } // Allow references to generated code diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt index 05c72719..4ef2b59b 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt @@ -3,9 +3,15 @@ package tech.relaycorp.letro.server.messages import org.bouncycastle.asn1.ASN1Encodable import org.bouncycastle.asn1.ASN1TaggedObject import org.bouncycastle.asn1.ASN1UTF8String +import tech.relaycorp.letro.utils.LetroOids import tech.relaycorp.letro.utils.asn1.ASN1Exception import tech.relaycorp.letro.utils.asn1.ASN1Utils import tech.relaycorp.letro.utils.i18n.parseLocale +import tech.relaycorp.veraid.Member +import tech.relaycorp.veraid.pki.MemberIdBundle +import tech.relaycorp.veraid.pki.PkiException +import java.security.PublicKey +import java.time.ZonedDateTime import java.util.Locale /** @@ -19,18 +25,68 @@ class AccountCreation( val assignedUserId: String, val veraidBundle: ByteArray, ) { + suspend fun validate(memberPublicKey: PublicKey) { + val (bundle, bundleMember) = verifyBundle() + + if (memberPublicKey != bundle.memberPublicKey) { + throw InvalidAccountCreationException( + "Member id bundle does not have expected member key", + ) + } + + val (assignedUserName, assignedOrg) = parseAssignedUserId() + if (assignedUserName != bundleMember.userName) { + throw InvalidAccountCreationException( + "Member id bundle does not have expected user name", + ) + } + if (assignedOrg != bundleMember.orgName) { + throw InvalidAccountCreationException( + "Member id bundle does not have expected org name", + ) + } + } + + private suspend fun verifyBundle(): Pair { + val bundle = try { + MemberIdBundle.deserialise(veraidBundle) + } catch (exc: PkiException) { + throw InvalidAccountCreationException("Member id bundle is malformed", exc) + } + + val now = ZonedDateTime.now() + val verificationPeriod = now..now + val bundleMember = try { + bundle.verify(LetroOids.LETRO_VERAID_OID, verificationPeriod) + } catch (exc: PkiException) { + throw InvalidAccountCreationException("Member id bundle is invalid", exc) + } + return Pair(bundle, bundleMember) + } + + private fun parseAssignedUserId(): Pair { + val assignedUserIdParts = assignedUserId.split("@", limit = 2) + val assignedUserName = if (assignedUserIdParts.size == 1) null else assignedUserIdParts[0] + val assignedOrg = if (assignedUserIdParts.size == 1) { + assignedUserIdParts[0] + } else { + assignedUserIdParts[1] + } + return Pair(assignedUserName, assignedOrg) + } + companion object { fun deserialise(serialised: ByteArray): AccountCreation { val accountCreationSequence = try { ASN1Utils.deserializeSequence(serialised) } catch (exc: ASN1Exception) { - throw MalformedAccountCreationException( + throw InvalidAccountCreationException( "AccountCreation should be a DER-encoded sequence", exc, ) } if (accountCreationSequence.size() < 4) { - throw MalformedAccountCreationException( + throw InvalidAccountCreationException( "AccountCreation SEQUENCE should have at least 4 items", ) } @@ -46,7 +102,7 @@ class AccountCreation( val requestedUserNameEncoded = try { ASN1UTF8String.getInstance(userNameTagged as ASN1TaggedObject, false) } catch (exc: RuntimeException) { - throw MalformedAccountCreationException( + throw InvalidAccountCreationException( "AccountCreation requestedUserName should be a DER-encoded UTF8String", exc, ) @@ -58,7 +114,7 @@ class AccountCreation( val localeEncoded = try { ASN1Utils.getVisibleString(localeTagged as ASN1TaggedObject) } catch (exc: RuntimeException) { - throw MalformedAccountCreationException( + throw InvalidAccountCreationException( "AccountCreation locale should be a DER-encoded VisibleString", exc, ) @@ -70,7 +126,7 @@ class AccountCreation( val assignedUserIdEncoded = try { ASN1UTF8String.getInstance(assignedUserIdTagged as ASN1TaggedObject, false) } catch (exc: RuntimeException) { - throw MalformedAccountCreationException( + throw InvalidAccountCreationException( "AccountCreation assignedUserId should be a DER-encoded UTF8String", exc, ) @@ -82,7 +138,7 @@ class AccountCreation( val veraidBundleEncoded = try { ASN1Utils.getOctetString(veraidBundleTagged as ASN1TaggedObject) } catch (exc: RuntimeException) { - throw MalformedAccountCreationException( + throw InvalidAccountCreationException( "AccountCreation veraidBundle should be a DER-encoded OCTET STRING", exc, ) diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/InvalidAccountCreationException.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/InvalidAccountCreationException.kt new file mode 100644 index 00000000..e898a2c6 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/InvalidAccountCreationException.kt @@ -0,0 +1,4 @@ +package tech.relaycorp.letro.server.messages + +class InvalidAccountCreationException(message: String?, cause: Throwable? = null) : + Exception(message, cause) diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt deleted file mode 100644 index 82c5abd4..00000000 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/MalformedAccountCreationException.kt +++ /dev/null @@ -1,4 +0,0 @@ -package tech.relaycorp.letro.server.messages - -class MalformedAccountCreationException(message: String?, cause: Throwable? = null) : - Exception(message, cause) diff --git a/app/src/main/java/tech/relaycorp/letro/utils/LetroOids.kt b/app/src/main/java/tech/relaycorp/letro/utils/LetroOids.kt new file mode 100644 index 00000000..b8260e36 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/LetroOids.kt @@ -0,0 +1,7 @@ +package tech.relaycorp.letro.utils + +object LetroOids { + private const val RELAYCORP_OID = "1.3.6.1.4.1.58708" + private const val LETRO_OID = "$RELAYCORP_OID.2" + const val LETRO_VERAID_OID = "$LETRO_OID.0" +} diff --git a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt index 207b04a7..851bb9e6 100644 --- a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt @@ -4,33 +4,190 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.should import io.kotest.matchers.shouldBe import io.kotest.matchers.types.beInstanceOf +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest import org.bouncycastle.asn1.DERNull import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.DERSequence import org.bouncycastle.asn1.DERTaggedObject import org.bouncycastle.asn1.DERUTF8String import org.bouncycastle.asn1.DERVisibleString +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair +import tech.relaycorp.letro.testing.veraid.VERAID_MEMBER_ID +import tech.relaycorp.letro.testing.veraid.VERAID_MEMBER_KEY_PAIR +import tech.relaycorp.letro.testing.veraid.VERAID_ORG_NAME +import tech.relaycorp.letro.testing.veraid.VERAID_USER_NAME +import tech.relaycorp.letro.utils.LetroOids import tech.relaycorp.letro.utils.asn1.ASN1Exception import tech.relaycorp.letro.utils.asn1.ASN1Utils import tech.relaycorp.letro.utils.i18n.normaliseString +import tech.relaycorp.veraid.Member +import tech.relaycorp.veraid.pki.MemberIdBundle +import tech.relaycorp.veraid.pki.PkiException +import java.security.PublicKey +import java.time.ZonedDateTime import java.util.Locale +@MockKExtension.ConfirmVerification +@MockKExtension.CheckUnnecessaryStub class AccountCreationTest { - val requestedUserName = "alice" + val requestedUserName = VERAID_USER_NAME val locale = Locale("EN", "GB") - val assignedUserId = "alice@example.com" - val veraidBundle = byteArrayOf(0x00) + val assignedUserId = VERAID_MEMBER_ID + val veraidBundle = "the bundle".toByteArray() - val nonAsciiUsername = "δΉ…ηΎŽε­" - val nonAsciiDomainName = "γ―γ˜γ‚γ‚ˆγ†.みんγͺ" + @Nested + inner class Validate { + private val accountCreation = AccountCreation( + requestedUserName, + locale, + assignedUserId, + veraidBundle, + ) + private val veraidMember = Member(VERAID_ORG_NAME, VERAID_USER_NAME) + private val memberPublicKey = VERAID_MEMBER_KEY_PAIR.public + + @AfterEach + fun clearMocks() { + unmockkObject(MemberIdBundle) + } + + @Test + fun `Malformed bundles should be refused`() = runTest { + mockkObject(MemberIdBundle) + val pkiException = PkiException("Whoops") + every { MemberIdBundle.deserialise(any()) } throws pkiException + + val exception = shouldThrow { + accountCreation.validate(memberPublicKey) + } + + exception.message shouldBe "Member id bundle is malformed" + exception.cause shouldBe pkiException + } + + @Test + fun `Should verify bundle against Letro service OID`() = runTest { + val mockBundle = mockMemberIdBundle(veraidMember, memberPublicKey) + + accountCreation.validate(memberPublicKey) + + coVerify { mockBundle.verify(LetroOids.LETRO_VERAID_OID, any()) } + } + + @Test + fun `Should be valid at the current time`() = runTest { + val mockBundle = mockMemberIdBundle(veraidMember, memberPublicKey) + val timeBefore = ZonedDateTime.now() + + accountCreation.validate(memberPublicKey) + + val timeAfterwards = ZonedDateTime.now() + coVerify { + mockBundle.verify( + any(), + match { + timeBefore <= it.start && + it.endInclusive <= timeAfterwards && + it.start == it.endInclusive + }, + ) + } + } + + @Test + fun `Member public key should match expected one`() = runTest { + val differentKeyPair = generateRSAKeyPair() + mockMemberIdBundle(veraidMember, differentKeyPair.public) + + val exception = shouldThrow { + accountCreation.validate(memberPublicKey) + } + + exception.message shouldBe "Member id bundle does not have expected member key" + } + + @Test + fun `Bundle member user name should match that of assigned id`() = runTest { + val differentMember = veraidMember.copy(userName = "not-${veraidMember.userName}") + mockMemberIdBundle(differentMember, memberPublicKey) + + val exception = shouldThrow { + accountCreation.validate(memberPublicKey) + } + + exception.message shouldBe "Member id bundle does not have expected user name" + } + + @Test + fun `Bundle member user name should be absent if assigned id is for bot`() = runTest { + val differentMember = veraidMember.copy(userName = null) + mockMemberIdBundle(differentMember, memberPublicKey) + val botAccountCreation = AccountCreation( + requestedUserName, + locale, + VERAID_ORG_NAME, // Just the domain name + veraidBundle, + ) + + botAccountCreation.validate(memberPublicKey) + } + + @Test + fun `Bundle member org should match that of assigned id`() = runTest { + val differentMember = veraidMember.copy(orgName = "not-${veraidMember.orgName}") + mockMemberIdBundle(differentMember, memberPublicKey) + + val exception = shouldThrow { + accountCreation.validate(memberPublicKey) + } + + exception.message shouldBe "Member id bundle does not have expected org name" + } + + @Test + fun `Validation error should be wrapped`() = runTest { + val mockBundle = mockMemberIdBundle(veraidMember, memberPublicKey) + val exception = PkiException("Something went wrong") + coEvery { mockBundle.verify(any(), any()) } throws exception + + val wrappedException = shouldThrow { + accountCreation.validate(memberPublicKey) + } + + wrappedException.message shouldBe "Member id bundle is invalid" + wrappedException.cause shouldBe exception + } + + private fun mockMemberIdBundle(member: Member, publicKey: PublicKey): MemberIdBundle { + val mockBundle = mockk() + coEvery { mockBundle.verify(any(), any()) } returns member + every { mockBundle.memberPublicKey } returns publicKey + + mockkObject(MemberIdBundle) + every { MemberIdBundle.deserialise(any()) } returns mockBundle + + return mockBundle + } + } @Nested inner class Deserialise { + val nonAsciiUsername = "δΉ…ηΎŽε­" + val nonAsciiDomainName = "γ―γ˜γ‚γ‚ˆγ†.みんγͺ" + @Test fun `Serialisation should be a DER-encoded sequence`() { - val exception = shouldThrow { + val exception = shouldThrow { AccountCreation.deserialise(byteArrayOf(0x00)) } @@ -45,7 +202,7 @@ class AccountCreationTest { false, ) - val exception = shouldThrow { + val exception = shouldThrow { AccountCreation.deserialise(malformedSerialisation) } @@ -65,7 +222,7 @@ class AccountCreationTest { ), ).encoded - val exception = shouldThrow { + val exception = shouldThrow { AccountCreation.deserialise(malformedSerialisation) } @@ -101,7 +258,7 @@ class AccountCreationTest { ), ).encoded - val exception = shouldThrow { + val exception = shouldThrow { AccountCreation.deserialise(malformedSerialisation) } @@ -137,7 +294,7 @@ class AccountCreationTest { ), ).encoded - val exception = shouldThrow { + val exception = shouldThrow { AccountCreation.deserialise(malformedSerialisation) } @@ -174,7 +331,7 @@ class AccountCreationTest { ), ).encoded - val exception = shouldThrow { + val exception = shouldThrow { AccountCreation.deserialise(malformedSerialisation) } 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 index 5d6fccaf..a893f8bd 100644 --- a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt @@ -9,7 +9,8 @@ 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.testing.veraid.VERAID_MEMBER_KEY_PAIR +import tech.relaycorp.letro.testing.veraid.VERAID_USER_NAME import tech.relaycorp.letro.utils.asn1.ASN1Utils import tech.relaycorp.letro.utils.crypto.RSASigning import tech.relaycorp.letro.utils.crypto.spkiEncode @@ -17,10 +18,10 @@ import tech.relaycorp.letro.utils.i18n.normaliseString import java.util.Locale class AccountRequestTest { - val userName = "alice" + val userName = VERAID_USER_NAME val locale = Locale("EN", "GB") - val keyPair = generateRSAKeyPair() + val keyPair = VERAID_MEMBER_KEY_PAIR @Nested inner class Serialize { @@ -77,7 +78,8 @@ class AccountRequestTest { val serialisation = request.serialise(keyPair.private) val requestSequence = parseRequestSequence(serialisation) - val localeEncoded = ASN1Utils.getVisibleString(requestSequence.getObjectAt(1) as ASN1TaggedObject) + val localeEncoded = + ASN1Utils.getVisibleString(requestSequence.getObjectAt(1) as ASN1TaggedObject) localeEncoded.string shouldBe locale.normaliseString() } diff --git a/app/src/test/java/tech/relaycorp/letro/testing/veraid/Stubs.kt b/app/src/test/java/tech/relaycorp/letro/testing/veraid/Stubs.kt new file mode 100644 index 00000000..546af329 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/testing/veraid/Stubs.kt @@ -0,0 +1,9 @@ +package tech.relaycorp.letro.testing.veraid + +import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair + +const val VERAID_USER_NAME = "alice" +const val VERAID_ORG_NAME = "example.com" +const val VERAID_MEMBER_ID = "$VERAID_USER_NAME@$VERAID_ORG_NAME" + +val VERAID_MEMBER_KEY_PAIR = generateRSAKeyPair() From a47c1bd79abfbe02da5faab33a2e1c1e7fdb5bc0 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Thu, 21 Sep 2023 19:25:59 +0100 Subject: [PATCH 11/20] Integrate new account request message --- .../1.json | 18 +++++++++-- .../relaycorp/letro/account/model/Account.kt | 2 ++ .../account/storage/AccountRepository.kt | 14 +++++++-- .../letro/awala/message/MessageType.kt | 2 +- .../RegistrationDomainProvider.kt | 25 ++++++++++++---- .../registration/RegistrationRepository.kt | 28 +++++++++++++++--- .../registration/RegistrationViewModel.kt | 8 +++-- .../main/res/raw/server_connection_params.der | Bin 418 -> 418 bytes 8 files changed, 77 insertions(+), 20 deletions(-) diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index 94477dd7..dba926c9 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "12466b7fa30a962804c156ee00283227", + "identityHash": "9ba7446ded03c6afc5e5dd21b7bae758", "entities": [ { "tableName": "account", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`veraId` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `isCreated` INTEGER NOT NULL, PRIMARY KEY(`veraId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`veraId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `locale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `isCreated` INTEGER NOT NULL, PRIMARY KEY(`veraId`))", "fields": [ { "fieldPath": "veraId", @@ -14,6 +14,18 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "requestedUserName", + "columnName": "requestedUserName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "isCurrent", "columnName": "isCurrent", @@ -212,7 +224,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12466b7fa30a962804c156ee00283227')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ba7446ded03c6afc5e5dd21b7bae758')" ] } } \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt b/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt index d4745d47..545dc9b8 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt @@ -11,6 +11,8 @@ const val TABLE_NAME_ACCOUNT = "account" data class Account( @PrimaryKey val veraId: String, + val requestedUserName: String, + val locale: String, val isCurrent: Boolean, val isCreated: Boolean = false, ) diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt index 0bfa8da3..29c1b509 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt @@ -9,11 +9,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account import tech.relaycorp.letro.main.MainViewModel +import tech.relaycorp.letro.utils.i18n.normaliseString +import java.util.Locale import javax.inject.Inject interface AccountRepository { val currentAccount: Flow - suspend fun createAccount(id: String) + suspend fun createAccount(requestedUserName: String, domainName: String, locale: Locale) suspend fun updateAccountId(id: String, newId: String) } @@ -44,10 +46,16 @@ class AccountRepositoryImpl @Inject constructor( } } - override suspend fun createAccount(id: String) { + override suspend fun createAccount( + requestedUserName: String, + domainName: String, + locale: Locale, + ) { accountDao.insert( Account( - veraId = id, + veraId = "$requestedUserName@$domainName", + requestedUserName = requestedUserName, + locale = locale.normaliseString(), isCurrent = true, ), ) diff --git a/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt index 80244e07..821b00e6 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt @@ -4,7 +4,7 @@ import android.util.Log import tech.relaycorp.letro.awala.AwalaManagerImpl sealed class MessageType(val value: String) { - object AccountCreationRequest : MessageType("application/vnd.relaycorp.letro.account-creation-request") + object AccountCreationRequest : MessageType("application/vnd.relaycorp.letro.account-request") object AccountCreationCompleted : MessageType("application/vnd.relaycorp.letro.account-creation-completed-tmp") object AuthorizeReceivingFromServer : MessageType("application/vnd+relaycorp.awala.pda-path") object ContactPairingRequest : MessageType("application/vnd.relaycorp.letro.pairing-request-tmp") diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt index 249d81f9..5965538c 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt @@ -5,21 +5,34 @@ import javax.inject.Inject interface RegistrationDomainProvider { fun getDomain(): String + fun getDomainLocale(): Locale } class RegistrationDomainProviderImpl @Inject constructor() : RegistrationDomainProvider { + private val lazyDomainLocale: Locale by lazy { + Locale.getDefault() + } + private val lazyDomain: String by lazy { val locale = Locale.getDefault() - when (locale.toString()) { - "en_GB" -> "@cuppa.fans" - "en_US" -> "@applepie.rocks" - "es_VE" -> "@guarapo.cafe" - else -> "@nautilus.ink" - } + DOMAIN_BY_LOCALE[locale.toString()] ?: FALLBACK_DOMAIN } override fun getDomain(): String { return lazyDomain } + + override fun getDomainLocale(): Locale { + return lazyDomainLocale + } + + private companion object { + const val FALLBACK_DOMAIN = "nautilus.ink" + val DOMAIN_BY_LOCALE = mapOf( + "en_GB" to "cuppa.fans", + "en_US" to "applepie.rocks", + "es_VE" to "guarapo.cafe", + ) + } } diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt index 86645864..0156e24c 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt @@ -8,10 +8,14 @@ import tech.relaycorp.letro.awala.AwalaManager import tech.relaycorp.letro.awala.message.AwalaOutgoingMessage import tech.relaycorp.letro.awala.message.MessageRecipient import tech.relaycorp.letro.awala.message.MessageType +import tech.relaycorp.letro.server.messages.AccountRequest +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.util.Locale import javax.inject.Inject interface RegistrationRepository { - fun createNewAccount(id: String) + fun createNewAccount(requestedUserName: String, domainName: String, locale: Locale) } class RegistrationRepositoryImpl @Inject constructor( @@ -21,17 +25,33 @@ class RegistrationRepositoryImpl @Inject constructor( private val scope = CoroutineScope(Dispatchers.IO) - override fun createNewAccount(id: String) { + override fun createNewAccount(requestedUserName: String, domainName: String, locale: Locale) { scope.launch { - accountRepository.createAccount(id) + accountRepository.createAccount(requestedUserName, domainName, locale) + + val keyPair = generateRSAKeyPair() + val creationRequest = AccountRequest( + requestedUserName, + locale, + keyPair.public, + ) awalaManager .sendMessage( outgoingMessage = AwalaOutgoingMessage( type = MessageType.AccountCreationRequest, - content = id.toByteArray(), + content = creationRequest.serialise(keyPair.private), ), recipient = MessageRecipient.Server(), ) } } + + /** + * Generate an ephemeral key pair temporarily (we'll persist it once VeraId is integrated). + */ + private fun generateRSAKeyPair(): KeyPair { + val keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + return keyGen.generateKeyPair() + } } diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt index 0adcf019..30abe16d 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt @@ -12,12 +12,12 @@ import javax.inject.Inject @HiltViewModel class RegistrationViewModel @Inject constructor( private val registrationRepository: RegistrationRepository, - domainProvider: RegistrationDomainProvider, + private val domainProvider: RegistrationDomainProvider, ) : ViewModel() { private val _uiState = MutableStateFlow( RegistrationScreenUiState( - domain = domainProvider.getDomain(), + domain = "@${domainProvider.getDomain()}", ), ) val uiState: StateFlow @@ -37,7 +37,9 @@ class RegistrationViewModel @Inject constructor( fun onCreateAccountClick() { registrationRepository.createNewAccount( - id = uiState.value.username + uiState.value.domain, + requestedUserName = uiState.value.username, + domainName = uiState.value.domain, + locale = domainProvider.getDomainLocale(), ) } diff --git a/app/src/main/res/raw/server_connection_params.der b/app/src/main/res/raw/server_connection_params.der index 97f63fb29c4b9b276b0823426069d13fdefb076a..77174f0fc10d2f388b50ecdc7c29467cbccec933 100644 GIT binary patch delta 375 zcmV--0f_#h1EK?vG=H8ahT0W4M_g<45^W5iv6t<%_v~~_DPW}!$UlZ%p`Mf-8R6Cg z!}KU<+2ZLXELZ@Z583?6KhD=?Rc_4f{d|Y(wx-BGUx}eoq3E&b>s9#ycI;^1CNDQC z^si6ib>g-gt<}GPQ6{g^6S(lV!PSCiwwB(-Y^-+GR7G*qAAdkJbvg(T*vdsuSZ6+~ z{ep_E+)H%u$Xxh3Hnvh#t@t5ykjJ31=Q#?aRj$SzLI4C7_J9CYsQQ|`Aw@}6Km|G7 zfL)kGJ8017AiM1CB4wOAoDkGXkwlH_Mb-cX(-1xGj4BvgN+)A(>aUI89bzixQ&{lb z6JQT{Rkj1C9&l=T0s{d60itJs2wwArh5P^kW`SEUSuhg@2P%e0&OHJF1_&yKNX|V2 z0S5y@00h}5afk`EI#?ni`#jw;v8oP8pN&YbZk&+xt@*q+f_SO6Z!UM-nz~ihTp-to Vztvs-${qv0d;cQE(iy1#Kl_Z=RZosYpoy27=P7vc+b3=&kXhgcVJ*8 zC;) zd7CCSoPjQi%;#oTw{K&y{i~wlQLBRC*ZB2tJogAT`Ru|*CdF@2zm#R&G=%w5^nqmJ zaZ1BzG$$~Puy8!49xKQ2#wmJG5TGs72Jg3-l1V@(Mm+i=`Q3tYI!6l V>mnKt_~rWvpr93-pylqK+B}38y8{3K From 43183e1afa2893ad20c5cf7753de692c3834adac Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Sun, 24 Sep 2023 10:03:28 +0100 Subject: [PATCH 12/20] fix merge --- .../1.json | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index dba926c9..77f44141 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,12 +2,18 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "9ba7446ded03c6afc5e5dd21b7bae758", + "identityHash": "400fe4e515d0db80e7b318d8ce2c5fb6", "entities": [ { "tableName": "account", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`veraId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `locale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `isCreated` INTEGER NOT NULL, PRIMARY KEY(`veraId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `veraId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `locale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `isCreated` INTEGER NOT NULL)", "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "veraId", "columnName": "veraId", @@ -40,12 +46,22 @@ } ], "primaryKey": { - "autoGenerate": false, + "autoGenerate": true, "columnNames": [ - "veraId" + "id" ] }, - "indices": [], + "indices": [ + { + "name": "index_account_veraId", + "unique": true, + "columnNames": [ + "veraId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_account_veraId` ON `${TABLE_NAME}` (`veraId`)" + } + ], "foreignKeys": [] }, { @@ -122,7 +138,7 @@ }, { "tableName": "conversations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`keyId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` BLOB NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `subject` TEXT)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`keyId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` BLOB NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `isRead` INTEGER NOT NULL, `subject` TEXT, `isArchived` INTEGER NOT NULL)", "fields": [ { "fieldPath": "keyId", @@ -148,11 +164,23 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "subject", "columnName": "subject", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -161,12 +189,22 @@ "keyId" ] }, - "indices": [], + "indices": [ + { + "name": "index_conversations_conversationId", + "unique": true, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversations_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], "foreignKeys": [] }, { "tableName": "messages", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` BLOB NOT NULL, `text` TEXT NOT NULL, `ownerVeraId` TEXT NOT NULL, `recipientVeraId` TEXT NOT NULL, `senderVeraId` TEXT NOT NULL, `sentAt` TEXT NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `conversationId` BLOB NOT NULL, `text` TEXT NOT NULL, `ownerVeraId` TEXT NOT NULL, `recipientVeraId` TEXT NOT NULL, `senderVeraId` TEXT NOT NULL, `sentAt` TEXT NOT NULL, FOREIGN KEY(`conversationId`) REFERENCES `conversations`(`conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -218,13 +256,25 @@ ] }, "indices": [], - "foreignKeys": [] + "foreignKeys": [ + { + "table": "conversations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "conversationId" + ], + "referencedColumns": [ + "conversationId" + ] + } + ] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ba7446ded03c6afc5e5dd21b7bae758')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '400fe4e515d0db80e7b318d8ce2c5fb6')" ] } } \ No newline at end of file From 6ead903fd625207a6295d7270443026b63b346af Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Sun, 24 Sep 2023 14:54:50 +0100 Subject: [PATCH 13/20] Integrate new account creation message (deserialisation). --- .../1.json | 32 +++++++--- .../relaycorp/letro/account/model/Account.kt | 42 ++++++++++++- .../letro/account/storage/AccountDao.kt | 7 ++- .../account/storage/AccountRepository.kt | 32 ++++++++-- .../letro/awala/message/MessageType.kt | 4 +- .../letro/contacts/ContactsViewModel.kt | 2 +- .../letro/contacts/ManageContactViewModel.kt | 10 +-- .../relaycorp/letro/contacts/model/Contact.kt | 2 +- .../contacts/storage/ContactsRepository.kt | 10 +-- .../letro/contacts/ui/ManageContactScreen.kt | 4 +- .../tech/relaycorp/letro/di/AwalaModule.kt | 6 +- .../relaycorp/letro/di/RegistrationModule.kt | 15 ++--- .../relaycorp/letro/main/MainViewModel.kt | 4 +- .../compose/ComposeNewMessageViewModel.kt | 4 +- .../list/ConversationsListViewModel.kt | 4 +- .../repository/ConversationsRepository.kt | 10 +-- .../registration/AccountCreationProcessor.kt | 53 ++++++++++++++++ .../registration/RegistrationRepository.kt | 4 +- .../registration/RegistrationViewModel.kt | 2 +- .../registration/dto/RegistrationResponse.kt | 6 -- .../RegistrationResponseIncomingMessage.kt | 11 ---- .../parser/RegistrationMessageParser.kt | 23 ------- .../processor/RegistrationMessageProcessor.kt | 22 ------- .../registration/ui/RegistrationScreen.kt | 2 +- .../letro/server/messages/AccountCreation.kt | 11 ++++ .../letro/utils/crypto/KeyEncoding.kt | 28 +++++++++ .../server/messages/AccountCreationTest.kt | 21 +++++++ .../letro/utils/crypto/KeyEncodingTest.kt | 63 ++++++++++++++----- 28 files changed, 291 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt delete mode 100644 app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponse.kt delete mode 100644 app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponseIncomingMessage.kt delete mode 100644 app/src/main/java/tech/relaycorp/letro/onboarding/registration/parser/RegistrationMessageParser.kt delete mode 100644 app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index 77f44141..2994d020 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "400fe4e515d0db80e7b318d8ce2c5fb6", + "identityHash": "dc43f5c1432dfa4a804e8ab2f5182994", "entities": [ { "tableName": "account", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `veraId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `locale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `isCreated` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `veraidId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `locale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `veraidPrivateKey` BLOB NOT NULL, `veraidMemberBundle` BLOB, `isCreated` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -15,8 +15,8 @@ "notNull": true }, { - "fieldPath": "veraId", - "columnName": "veraId", + "fieldPath": "veraidId", + "columnName": "veraidId", "affinity": "TEXT", "notNull": true }, @@ -38,6 +38,18 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "veraidPrivateKey", + "columnName": "veraidPrivateKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "veraidMemberBundle", + "columnName": "veraidMemberBundle", + "affinity": "BLOB", + "notNull": false + }, { "fieldPath": "isCreated", "columnName": "isCreated", @@ -53,20 +65,20 @@ }, "indices": [ { - "name": "index_account_veraId", + "name": "index_account_veraidId", "unique": true, "columnNames": [ - "veraId" + "veraidId" ], "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_account_veraId` ON `${TABLE_NAME}` (`veraId`)" + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_account_veraidId` ON `${TABLE_NAME}` (`veraidId`)" } ], "foreignKeys": [] }, { "tableName": "contacts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `alias` TEXT, `contactEndpointId` TEXT, `status` INTEGER NOT NULL, FOREIGN KEY(`ownerVeraId`) REFERENCES `account`(`veraId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ownerVeraId` TEXT NOT NULL, `contactVeraId` TEXT NOT NULL, `alias` TEXT, `contactEndpointId` TEXT, `status` INTEGER NOT NULL, FOREIGN KEY(`ownerVeraId`) REFERENCES `account`(`veraidId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -131,7 +143,7 @@ "ownerVeraId" ], "referencedColumns": [ - "veraId" + "veraidId" ] } ] @@ -274,7 +286,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '400fe4e515d0db80e7b318d8ce2c5fb6')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dc43f5c1432dfa4a804e8ab2f5182994')" ] } } \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt b/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt index ff863b3d..f546fae1 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt @@ -8,14 +8,50 @@ const val TABLE_NAME_ACCOUNT = "account" @Entity( tableName = TABLE_NAME_ACCOUNT, - indices = [Index("veraId", unique = true)], + indices = [Index("veraidId", unique = true)], ) data class Account( @PrimaryKey(autoGenerate = true) val id: Long = 0L, - val veraId: String, + val veraidId: String, val requestedUserName: String, val locale: String, val isCurrent: Boolean, + // TODO: Encrypt key when integrating VeraId (https://relaycorp.atlassian.net/browse/LTR-55) + val veraidPrivateKey: ByteArray, + val veraidMemberBundle: ByteArray? = null, val isCreated: Boolean = false, -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Account + + if (id != other.id) return false + if (veraidId != other.veraidId) return false + if (requestedUserName != other.requestedUserName) return false + if (locale != other.locale) return false + if (isCurrent != other.isCurrent) return false + if (!veraidPrivateKey.contentEquals(other.veraidPrivateKey)) return false + if (veraidMemberBundle != null) { + if (other.veraidMemberBundle == null) return false + if (!veraidMemberBundle.contentEquals(other.veraidMemberBundle)) return false + } else if (other.veraidMemberBundle != null) return false + if (isCreated != other.isCreated) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + veraidId.hashCode() + result = 31 * result + requestedUserName.hashCode() + result = 31 * result + locale.hashCode() + result = 31 * result + isCurrent.hashCode() + result = 31 * result + veraidPrivateKey.contentHashCode() + result = 31 * result + (veraidMemberBundle?.contentHashCode() ?: 0) + result = 31 * result + isCreated.hashCode() + return result + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt index 8274e816..f495c9f2 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt @@ -20,6 +20,9 @@ interface AccountDao { @Update suspend fun update(entity: Account): Int - @Query("SELECT * FROM $TABLE_NAME_ACCOUNT WHERE veraId=:veraId") - suspend fun getByVeraId(veraId: String): Account? + @Query("SELECT * FROM $TABLE_NAME_ACCOUNT WHERE id=:id") + suspend fun getById(id: Long): Account? + + @Query("SELECT * FROM $TABLE_NAME_ACCOUNT WHERE requestedUserName=:requestedUserName AND locale=:locale") + suspend fun getByRequestParams(requestedUserName: String, locale: String): Account? } diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt index 29c1b509..b438aa8a 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt @@ -10,14 +10,25 @@ import kotlinx.coroutines.launch import tech.relaycorp.letro.account.model.Account import tech.relaycorp.letro.main.MainViewModel import tech.relaycorp.letro.utils.i18n.normaliseString +import java.security.PrivateKey import java.util.Locale import javax.inject.Inject interface AccountRepository { val currentAccount: Flow - suspend fun createAccount(requestedUserName: String, domainName: String, locale: Locale) + suspend fun createAccount( + requestedUserName: String, + domainName: String, + locale: Locale, + veraidPrivateKey: PrivateKey, + ) + + suspend fun getByRequest( + requestedUserName: String, + locale: Locale, + ): Account? - suspend fun updateAccountId(id: String, newId: String) + suspend fun completeRegistration(id: Long, veraidId: String, veraidBundle: ByteArray) } class AccountRepositoryImpl @Inject constructor( @@ -50,22 +61,31 @@ class AccountRepositoryImpl @Inject constructor( requestedUserName: String, domainName: String, locale: Locale, + veraidPrivateKey: PrivateKey, ) { accountDao.insert( Account( - veraId = "$requestedUserName@$domainName", + veraidId = "$requestedUserName@$domainName", requestedUserName = requestedUserName, locale = locale.normaliseString(), + veraidPrivateKey = veraidPrivateKey.encoded, isCurrent = true, ), ) } - override suspend fun updateAccountId(id: String, newId: String) { - accountDao.getByVeraId(id)?.let { + override suspend fun getByRequest(requestedUserName: String, locale: Locale): Account? = + accountDao.getByRequestParams( + requestedUserName = requestedUserName, + locale = locale.normaliseString(), + ) + + override suspend fun completeRegistration(id: Long, veraidId: String, veraidBundle: ByteArray) { + accountDao.getById(id)?.let { accountDao.update( it.copy( - veraId = newId, + veraidId = veraidId, + veraidMemberBundle = veraidBundle, isCreated = true, ), ) diff --git a/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt index 821b00e6..2a99d5c2 100644 --- a/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt +++ b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt @@ -5,7 +5,7 @@ import tech.relaycorp.letro.awala.AwalaManagerImpl sealed class MessageType(val value: String) { object AccountCreationRequest : MessageType("application/vnd.relaycorp.letro.account-request") - object AccountCreationCompleted : MessageType("application/vnd.relaycorp.letro.account-creation-completed-tmp") + object AccountCreation : MessageType("application/vnd.relaycorp.letro.account-creation") object AuthorizeReceivingFromServer : MessageType("application/vnd+relaycorp.awala.pda-path") object ContactPairingRequest : MessageType("application/vnd.relaycorp.letro.pairing-request-tmp") object ContactPairingMatch : MessageType("application/vnd.relaycorp.letro.pairing-match-tmp") @@ -18,7 +18,7 @@ sealed class MessageType(val value: String) { fun from(type: String): MessageType { return when (type) { AccountCreationRequest.value -> AccountCreationRequest - AccountCreationCompleted.value -> AccountCreationCompleted + AccountCreation.value -> AccountCreation AuthorizeReceivingFromServer.value -> AuthorizeReceivingFromServer ContactPairingRequest.value -> ContactPairingRequest ContactPairingMatch.value -> ContactPairingMatch diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt index d542c96f..5bac62c6 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ContactsViewModel.kt @@ -119,7 +119,7 @@ class ContactsViewModel @Inject constructor( contactsCollectionJob = null if (account != null) { contactsCollectionJob = viewModelScope.launch { - contactsRepository.getContacts(account.veraId).collect { + contactsRepository.getContacts(account.veraidId).collect { _contacts.emit(it.filter { it.status == ContactPairingStatus.COMPLETED }) } } diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt index 9fa3d2d3..0051add8 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt @@ -72,7 +72,7 @@ class ManageContactViewModel @Inject constructor( editingContact = contactToEdit _uiState.update { it.copy( - veraId = contactToEdit.contactVeraId, + veraidId = contactToEdit.contactVeraId, alias = contactToEdit.alias, isVeraIdInputEnabled = false, ) @@ -102,7 +102,7 @@ class ManageContactViewModel @Inject constructor( val trimmedId = id.trim() _uiState.update { it.copy( - veraId = trimmedId, + veraidId = trimmedId, isSentRequestAgainHintVisible = contacts.any { it.contactVeraId == trimmedId && it.status == ContactPairingStatus.REQUEST_SENT }, ) } @@ -135,7 +135,7 @@ class ManageContactViewModel @Inject constructor( EDIT_CONTACT -> { updateContact() viewModelScope.launch { - _onEditContactCompleted.emit(uiState.value.veraId) + _onEditContactCompleted.emit(uiState.value.veraidId) } } else -> throw IllegalStateException("Unknown screen type: $screenType") @@ -177,7 +177,7 @@ class ManageContactViewModel @Inject constructor( contactsRepository.addNewContact( contact = Contact( ownerVeraId = currentAccountId, - contactVeraId = uiState.value.veraId, + contactVeraId = uiState.value.veraidId, alias = uiState.value.alias?.nullIfBlankOrEmpty(), status = ContactPairingStatus.REQUEST_SENT, ), @@ -212,7 +212,7 @@ class ManageContactViewModel @Inject constructor( data class PairWithOthersUiState( val manageContactTexts: ManageContactTexts, - val veraId: String = "", + val veraidId: String = "", val alias: String? = null, val isActionButtonEnabled: Boolean = false, val isSentRequestAgainHintVisible: Boolean = false, diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt b/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt index d84f1532..15447610 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/model/Contact.kt @@ -19,7 +19,7 @@ const val TABLE_NAME_CONTACTS = "contacts" foreignKeys = [ ForeignKey( entity = Account::class, - parentColumns = ["veraId"], + parentColumns = ["veraidId"], childColumns = ["ownerVeraId"], onDelete = ForeignKey.CASCADE, ), diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt index 14ffcef7..78ab4f7d 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/storage/ContactsRepository.kt @@ -114,7 +114,7 @@ class ContactsRepositoryImpl @Inject constructor( override fun saveRequestWasOnceSent() { val currentAccount = currentAccount ?: return scope.launch { - preferences.putBoolean(getContactRequestHasEverBeenSentKey(currentAccount.veraId), true) + preferences.putBoolean(getContactRequestHasEverBeenSentKey(currentAccount.veraidId), true) updateContactsState(currentAccount) } } @@ -137,9 +137,9 @@ class ContactsRepositoryImpl @Inject constructor( val isPairedContactExist = contacts .value .any { - it.ownerVeraId == account.veraId && it.status == ContactPairingStatus.COMPLETED + it.ownerVeraId == account.veraidId && it.status == ContactPairingStatus.COMPLETED } - val isPairRequestWasEverSent = preferences.getBoolean(getContactRequestHasEverBeenSentKey(account.veraId), false) + val isPairRequestWasEverSent = preferences.getBoolean(getContactRequestHasEverBeenSentKey(account.veraidId), false) _contactsState.emit( ContactsState( isPairedContactExist = isPairedContactExist, @@ -149,8 +149,8 @@ class ContactsRepositoryImpl @Inject constructor( } private fun getContactRequestHasEverBeenSentKey( - veraId: String, - ) = "${KEY_CONTACT_REQUEST_HAS_EVER_BEEN_SENT_PREFIX}$veraId" + veraidId: String, + ) = "${KEY_CONTACT_REQUEST_HAS_EVER_BEEN_SENT_PREFIX}$veraidId" private companion object { private const val TAG = "ContactsRepository" diff --git a/app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt index 32a30fc7..ad261c5b 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ui/ManageContactScreen.kt @@ -61,7 +61,7 @@ fun ManageContactScreen( } else { ActionTakingScreen( actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.PairingRequestSent( - boldPartOfMessage = uiState.veraId, + boldPartOfMessage = uiState.veraidId, onGotItClicked = { viewModel.onGotItClick() }, @@ -110,7 +110,7 @@ private fun ManageContactView( modifier = Modifier.height(8.dp), ) LetroOutlinedTextField( - value = uiState.veraId, + value = uiState.veraidId, onValueChange = viewModel::onIdChanged, label = R.string.general_id, hintText = stringResource(id = R.string.new_contact_id_hint), diff --git a/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt b/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt index 5ce34155..36e56cc9 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/AwalaModule.kt @@ -15,7 +15,7 @@ import tech.relaycorp.letro.awala.processor.AwalaMessageProcessorImpl import tech.relaycorp.letro.awala.processor.UnknownMessageProcessor import tech.relaycorp.letro.messages.processor.NewConversationProcessor import tech.relaycorp.letro.messages.processor.NewMessageProcessor -import tech.relaycorp.letro.onboarding.registration.processor.RegistrationMessageProcessor +import tech.relaycorp.letro.onboarding.registration.AccountCreationProcessor import tech.relaycorp.letro.pairing.processor.ContactPairingAuthorizationProcessor import tech.relaycorp.letro.pairing.processor.ContactPairingMatchProcessor import javax.inject.Singleton @@ -26,7 +26,7 @@ object AwalaModule { @Provides fun provideMessageProcessor( - registrationMessageProcessor: RegistrationMessageProcessor, + accountCreationProcessor: AccountCreationProcessor, contactPairingMatchProcessor: ContactPairingMatchProcessor, contactPairingAuthorizationProcessor: ContactPairingAuthorizationProcessor, newConversationProcessor: NewConversationProcessor, @@ -34,7 +34,7 @@ object AwalaModule { unknownMessageProcessor: UnknownMessageProcessor, ): AwalaMessageProcessor { val processors = mapOf( - MessageType.AccountCreationCompleted to registrationMessageProcessor, + MessageType.AccountCreation to accountCreationProcessor, MessageType.ContactPairingMatch to contactPairingMatchProcessor, MessageType.ContactPairingAuthorization to contactPairingAuthorizationProcessor, MessageType.NewConversation to newConversationProcessor, diff --git a/app/src/main/java/tech/relaycorp/letro/di/RegistrationModule.kt b/app/src/main/java/tech/relaycorp/letro/di/RegistrationModule.kt index 2b5332b2..aed699b3 100644 --- a/app/src/main/java/tech/relaycorp/letro/di/RegistrationModule.kt +++ b/app/src/main/java/tech/relaycorp/letro/di/RegistrationModule.kt @@ -6,14 +6,12 @@ import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.components.SingletonComponent +import tech.relaycorp.letro.onboarding.registration.AccountCreationProcessor +import tech.relaycorp.letro.onboarding.registration.AccountCreationProcessorImpl import tech.relaycorp.letro.onboarding.registration.RegistrationDomainProvider import tech.relaycorp.letro.onboarding.registration.RegistrationDomainProviderImpl import tech.relaycorp.letro.onboarding.registration.RegistrationRepository import tech.relaycorp.letro.onboarding.registration.RegistrationRepositoryImpl -import tech.relaycorp.letro.onboarding.registration.parser.RegistrationMessageParser -import tech.relaycorp.letro.onboarding.registration.parser.RegistrationMessageParserImpl -import tech.relaycorp.letro.onboarding.registration.processor.RegistrationMessageProcessor -import tech.relaycorp.letro.onboarding.registration.processor.RegistrationMessageProcessorImpl import javax.inject.Singleton @Module @@ -37,13 +35,8 @@ interface RegistrationModuleSingleton { impl: RegistrationRepositoryImpl, ): RegistrationRepository - @Binds - fun bindRegistrationMessageParser( - impl: RegistrationMessageParserImpl, - ): RegistrationMessageParser - @Binds fun bindRegistrationMessageProcessor( - impl: RegistrationMessageProcessorImpl, - ): RegistrationMessageProcessor + impl: AccountCreationProcessorImpl, + ): AccountCreationProcessor } diff --git a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt index ef1e4cf5..39fedc7e 100644 --- a/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/main/MainViewModel.kt @@ -51,7 +51,7 @@ class MainViewModel @Inject constructor( _uiState.update { if (account != null) { it.copy( - currentAccount = account.veraId, + currentAccount = account.veraidId, isCurrentAccountCreated = account.isCreated, ) } else { @@ -93,7 +93,7 @@ class MainViewModel @Inject constructor( } fun onShareIdClick() { - currentAccount?.veraId?.let { accountId -> + currentAccount?.veraidId?.let { accountId -> viewModelScope.launch { _joinMeOnLetroSignal.emit(getJoinMeLink(accountId)) } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt index 91d601fa..4dd3f4ca 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/compose/ComposeNewMessageViewModel.kt @@ -64,10 +64,10 @@ class CreateNewMessageViewModel @Inject constructor( it?.let { account -> _uiState.update { state -> state.copy( - sender = account.veraId, + sender = account.veraidId, ) } - startCollectingConnectedContacts(account.veraId) + startCollectingConnectedContacts(account.veraidId) } } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt index 607f77a6..e40c2c54 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/list/ConversationsListViewModel.kt @@ -67,7 +67,7 @@ class ConversationsListViewModel @Inject constructor( accountRepository.currentAccount.collect { currentAccount = it if (it != null) { - _isOnboardingMessageVisible.emit(!conversationsOnboardingManager.isOnboardingMessageWasShown(it.veraId)) + _isOnboardingMessageVisible.emit(!conversationsOnboardingManager.isOnboardingMessageWasShown(it.veraidId)) } else { _isOnboardingMessageVisible.emit(false) } @@ -122,7 +122,7 @@ class ConversationsListViewModel @Inject constructor( fun onCloseOnboardingButtonClick() { viewModelScope.launch { currentAccount?.let { - conversationsOnboardingManager.saveOnboardingMessageShown(it.veraId) + conversationsOnboardingManager.saveOnboardingMessageShown(it.veraidId) _isOnboardingMessageVisible.emit(false) } } diff --git a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt index f4863ce6..817b58dc 100644 --- a/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/messages/repository/ConversationsRepository.kt @@ -198,7 +198,7 @@ class ConversationsRepositoryImpl @Inject constructor( private fun startCollectContacts(account: Account) { contactsCollectionJob = scope.launch { - contactsRepository.getContacts(account.veraId).collect { + contactsRepository.getContacts(account.veraidId).collect { contacts.emit(it) } } @@ -216,10 +216,10 @@ class ConversationsRepositoryImpl @Inject constructor( _conversations.emit(conversations) _extendedConversations.emit( conversationsConverter.convert( - conversations = conversations.filter { it.ownerVeraId == account.veraId }, - messages = messages.filter { it.ownerVeraId == account.veraId }, - contacts = contacts.filter { it.ownerVeraId == account.veraId }, - ownerVeraId = account.veraId, + conversations = conversations.filter { it.ownerVeraId == account.veraidId }, + messages = messages.filter { it.ownerVeraId == account.veraidId }, + contacts = contacts.filter { it.ownerVeraId == account.veraidId }, + ownerVeraId = account.veraidId, ), ) }.collect() diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt new file mode 100644 index 00000000..c3d58eb1 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt @@ -0,0 +1,53 @@ +package tech.relaycorp.letro.onboarding.registration + +import tech.relaycorp.awaladroid.messaging.IncomingMessage +import tech.relaycorp.letro.account.storage.AccountRepository +import tech.relaycorp.letro.awala.AwalaManager +import tech.relaycorp.letro.awala.processor.AwalaMessageProcessor +import tech.relaycorp.letro.server.messages.AccountCreation +import tech.relaycorp.letro.server.messages.InvalidAccountCreationException +import tech.relaycorp.letro.utils.crypto.deserialiseKeyPair +import java.util.logging.Level +import java.util.logging.Logger.getLogger +import javax.inject.Inject + +interface AccountCreationProcessor : AwalaMessageProcessor + +class AccountCreationProcessorImpl @Inject constructor( + private val accountRepository: AccountRepository, +) : AccountCreationProcessor { + private val logger = getLogger(javaClass.name) + + override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { + val accountCreation = try { + AccountCreation.deserialise(message.content) + } catch (exc: InvalidAccountCreationException) { + logger.log(Level.WARNING, "Malformed account creation message", exc) + return + } + + val account = accountRepository.getByRequest( + accountCreation.requestedUserName, + accountCreation.locale, + ) + if (account == null) { + logger.warning("No account found for creation message ($accountCreation)") + return + } + + val veraidKeyPair = account.veraidPrivateKey.deserialiseKeyPair() + try { + accountCreation.validate(veraidKeyPair.public) + } catch (exc: InvalidAccountCreationException) { + logger.log(Level.WARNING, "Invalid account creation ($accountCreation)", exc) + return + } + + accountRepository.completeRegistration( + account.id, + accountCreation.assignedUserId, + accountCreation.veraidBundle, + ) + logger.info("Completed account creation ($accountCreation)") + } +} diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt index 0156e24c..be6e42e9 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationRepository.kt @@ -27,9 +27,9 @@ class RegistrationRepositoryImpl @Inject constructor( override fun createNewAccount(requestedUserName: String, domainName: String, locale: Locale) { scope.launch { - accountRepository.createAccount(requestedUserName, domainName, locale) - val keyPair = generateRSAKeyPair() + accountRepository.createAccount(requestedUserName, domainName, locale, keyPair.private) + val creationRequest = AccountRequest( requestedUserName, locale, diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt index 30abe16d..a654b189 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationViewModel.kt @@ -17,7 +17,7 @@ class RegistrationViewModel @Inject constructor( private val _uiState = MutableStateFlow( RegistrationScreenUiState( - domain = "@${domainProvider.getDomain()}", + domain = domainProvider.getDomain(), ), ) val uiState: StateFlow diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponse.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponse.kt deleted file mode 100644 index a2bc3460..00000000 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.relaycorp.letro.onboarding.registration.dto - -data class RegistrationResponse( - val requestedVeraId: String, - val assignedVeraId: String, -) diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponseIncomingMessage.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponseIncomingMessage.kt deleted file mode 100644 index eff06d0c..00000000 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/dto/RegistrationResponseIncomingMessage.kt +++ /dev/null @@ -1,11 +0,0 @@ -package tech.relaycorp.letro.onboarding.registration.dto - -import tech.relaycorp.letro.awala.message.AwalaIncomingMessage -import tech.relaycorp.letro.awala.message.MessageType - -data class RegistrationResponseIncomingMessage( - override val content: RegistrationResponse, -) : AwalaIncomingMessage { - override val type: MessageType - get() = MessageType.AccountCreationCompleted -} diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/parser/RegistrationMessageParser.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/parser/RegistrationMessageParser.kt deleted file mode 100644 index f930520f..00000000 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/parser/RegistrationMessageParser.kt +++ /dev/null @@ -1,23 +0,0 @@ -package tech.relaycorp.letro.onboarding.registration.parser - -import tech.relaycorp.letro.awala.parser.AwalaMessageParser -import tech.relaycorp.letro.onboarding.registration.dto.RegistrationResponse -import tech.relaycorp.letro.onboarding.registration.dto.RegistrationResponseIncomingMessage -import java.nio.charset.Charset -import javax.inject.Inject - -interface RegistrationMessageParser : AwalaMessageParser - -class RegistrationMessageParserImpl @Inject constructor() : RegistrationMessageParser { - - override fun parse(content: ByteArray): RegistrationResponseIncomingMessage { - val veraIds = content.toString(Charset.defaultCharset()).split(",") - val response = RegistrationResponse( - requestedVeraId = veraIds[0], - assignedVeraId = veraIds[1], - ) - return RegistrationResponseIncomingMessage( - content = response, - ) - } -} diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt deleted file mode 100644 index 265afdca..00000000 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/processor/RegistrationMessageProcessor.kt +++ /dev/null @@ -1,22 +0,0 @@ -package tech.relaycorp.letro.onboarding.registration.processor - -import tech.relaycorp.awaladroid.messaging.IncomingMessage -import tech.relaycorp.letro.account.storage.AccountRepository -import tech.relaycorp.letro.awala.AwalaManager -import tech.relaycorp.letro.awala.processor.AwalaMessageProcessor -import tech.relaycorp.letro.onboarding.registration.dto.RegistrationResponseIncomingMessage -import tech.relaycorp.letro.onboarding.registration.parser.RegistrationMessageParser -import javax.inject.Inject - -interface RegistrationMessageProcessor : AwalaMessageProcessor - -class RegistrationMessageProcessorImpl @Inject constructor( - private val parser: RegistrationMessageParser, - private val accountRepository: AccountRepository, -) : RegistrationMessageProcessor { - - override suspend fun process(message: IncomingMessage, awalaManager: AwalaManager) { - val response = parser.parse(message.content) as RegistrationResponseIncomingMessage - accountRepository.updateAccountId(response.content.requestedVeraId, response.content.assignedVeraId) - } -} diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt index 477e0a5f..855e9b40 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/ui/RegistrationScreen.kt @@ -64,7 +64,7 @@ fun RegistrationScreen( value = uiState.username, onValueChange = { viewModel.onUsernameInput(it) }, hintText = stringResource(id = R.string.onboarding_create_account_id_placeholder), - suffixText = uiState.domain, + suffixText = "@${uiState.domain}", isError = uiState.isError, label = R.string.general_id, ) diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt index 4ef2b59b..a4e8f103 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt @@ -6,6 +6,7 @@ import org.bouncycastle.asn1.ASN1UTF8String import tech.relaycorp.letro.utils.LetroOids import tech.relaycorp.letro.utils.asn1.ASN1Exception import tech.relaycorp.letro.utils.asn1.ASN1Utils +import tech.relaycorp.letro.utils.i18n.normaliseString import tech.relaycorp.letro.utils.i18n.parseLocale import tech.relaycorp.veraid.Member import tech.relaycorp.veraid.pki.MemberIdBundle @@ -75,7 +76,17 @@ class AccountCreation( return Pair(assignedUserName, assignedOrg) } + override fun toString(): String { + val params = listOf( + "requestedUserName=$requestedUserName", + "locale=${locale.normaliseString()}", + "assignedUserId=$assignedUserId", + ).joinToString(", ") + return "AccountCreation($params)" + } + companion object { + @Throws(InvalidAccountCreationException::class) fun deserialise(serialised: ByteArray): AccountCreation { val accountCreationSequence = try { ASN1Utils.deserializeSequence(serialised) diff --git a/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt b/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt index 9e3f86ed..a3cc72e5 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt @@ -1,3 +1,5 @@ +@file:JvmName("KeyEncoding") + package tech.relaycorp.letro.utils.crypto import org.bouncycastle.asn1.ASN1BitString @@ -9,7 +11,13 @@ import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers import org.bouncycastle.asn1.pkcs.RSASSAPSSparams import org.bouncycastle.asn1.x509.AlgorithmIdentifier import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import java.security.KeyFactory +import java.security.KeyPair import java.security.PublicKey +import java.security.interfaces.RSAPrivateCrtKey +import java.security.spec.InvalidKeySpecException +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec private val rsaPssSha256Mgf1Algorithm = AlgorithmIdentifier( PKCSObjectIdentifiers.id_RSASSA_PSS, @@ -39,3 +47,23 @@ fun PublicKey.spkiEncode(): SubjectPublicKeyInfo { val keyEncoded = ASN1BitString.getInstance(keyWrapperEncoded.getObjectAt(1)) return SubjectPublicKeyInfo(rsaPssSha256Mgf1Algorithm, keyEncoded.bytes) } + +@Throws(IllegalArgumentException::class) +private fun ByteArray.deserialiseRsaPrivateKey(): RSAPrivateCrtKey { + val privateKeySpec = PKCS8EncodedKeySpec(this) + val keyFactory = KeyFactory.getInstance("RSA", BC_PROVIDER) + return try { + keyFactory.generatePrivate(privateKeySpec) as RSAPrivateCrtKey + } catch (exc: InvalidKeySpecException) { + throw IllegalArgumentException("Only RSA keys are supported", exc) + } +} + +@Throws(IllegalArgumentException::class) +fun ByteArray.deserialiseKeyPair(): KeyPair { + val privateKey = this.deserialiseRsaPrivateKey() + val keyFactory = KeyFactory.getInstance("RSA") + val publicKeySpec = RSAPublicKeySpec(privateKey.modulus, privateKey.publicExponent) + val publicKey = keyFactory.generatePublic(publicKeySpec) + return KeyPair(publicKey, privateKey) +} diff --git a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt index 851bb9e6..e9c9c297 100644 --- a/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt @@ -355,6 +355,27 @@ class AccountCreationTest { } } + @Nested + inner class ToString { + @Test + fun `Should include requested user name, locale and assigned user id`() { + val accountCreation = AccountCreation( + requestedUserName, + locale, + assignedUserId, + veraidBundle, + ) + + val localeNormalised = locale.normaliseString() + val params = listOf( + "requestedUserName=$requestedUserName", + "locale=$localeNormalised", + "assignedUserId=$assignedUserId", + ).joinToString(", ") + accountCreation.toString() shouldBe "AccountCreation($params)" + } + } + private fun AccountCreation.serialise(): ByteArray { return ASN1Utils.serializeSequence( listOf( diff --git a/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt index 16b77e11..e244240d 100644 --- a/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt @@ -1,7 +1,9 @@ package tech.relaycorp.letro.utils.crypto import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.should import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf import org.bouncycastle.asn1.ASN1BitString import org.bouncycastle.asn1.DERNull import org.bouncycastle.asn1.DERSequence @@ -12,22 +14,20 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import tech.relaycorp.letro.testing.crypto.generateRSAKeyPair +import java.security.KeyPair import java.security.KeyPairGenerator -import java.security.PublicKey +import java.security.spec.InvalidKeySpecException class KeyEncodingTest { + val rsaKeyPair = generateRSAKeyPair() + val dsaKeyPair = generateDsaKeyPair() + @Nested inner class PublicKeySpkiEncoding { - val rsaPublicKey: PublicKey = generateRSAKeyPair().public - @Test fun `Non-RSA keys should be refused`() { - val keyGen = KeyPairGenerator.getInstance("DSA", BC_PROVIDER) - keyGen.initialize(1024) - val keyPair = keyGen.generateKeyPair() - val exception = shouldThrow { - keyPair.public.spkiEncode() + dsaKeyPair.public.spkiEncode() } exception.message shouldBe "Only RSA keys are supported" @@ -37,7 +37,7 @@ class KeyEncodingTest { inner class Algorithm { @Test fun `Algorithm should be RSA-PSS`() { - val encoding = rsaPublicKey.spkiEncode() + val encoding = rsaKeyPair.public.spkiEncode() encoding.algorithm.algorithm shouldBe PKCSObjectIdentifiers.id_RSASSA_PSS } @@ -46,7 +46,7 @@ class KeyEncodingTest { inner class Params { @Test fun `Hash should be SHA-256`() { - val encoding = rsaPublicKey.spkiEncode() + val encoding = rsaKeyPair.public.spkiEncode() val parameters = encoding.algorithm.parameters as RSASSAPSSparams parameters.hashAlgorithm.algorithm shouldBe NISTObjectIdentifiers.id_sha256 @@ -55,7 +55,7 @@ class KeyEncodingTest { @Test fun `MGF should be MGF1 with SHA-256`() { - val encoding = rsaPublicKey.spkiEncode() + val encoding = rsaKeyPair.public.spkiEncode() val parameters = encoding.algorithm.parameters as RSASSAPSSparams parameters.maskGenAlgorithm.algorithm shouldBe PKCSObjectIdentifiers.id_mgf1 @@ -65,7 +65,7 @@ class KeyEncodingTest { @Test fun `Salt length should be 32`() { - val encoding = rsaPublicKey.spkiEncode() + val encoding = rsaKeyPair.public.spkiEncode() val parameters = encoding.algorithm.parameters as RSASSAPSSparams parameters.saltLength.intValueExact() shouldBe 32 @@ -73,7 +73,7 @@ class KeyEncodingTest { @Test fun `Trailer field should be 1`() { - val encoding = rsaPublicKey.spkiEncode() + val encoding = rsaKeyPair.public.spkiEncode() val parameters = encoding.algorithm.parameters as RSASSAPSSparams parameters.trailerField.intValueExact() shouldBe 1 @@ -83,11 +83,44 @@ class KeyEncodingTest { @Test fun `Key should be just the key without the algorithm`() { - val encoding = rsaPublicKey.spkiEncode() + val encoding = rsaKeyPair.public.spkiEncode() - val keyWrapperEncoded = DERSequence.getInstance(rsaPublicKey.encoded) + val keyWrapperEncoded = DERSequence.getInstance(rsaKeyPair.public.encoded) val keyEncoded = ASN1BitString.getInstance(keyWrapperEncoded.getObjectAt(1)) encoding.publicKeyData shouldBe keyEncoded } } + + @Nested + inner class ByteArrayDeserialiseKeyPair { + @Test + fun `Non-RSA keys should be refused`() { + val exception = shouldThrow { + dsaKeyPair.private.encoded.deserialiseKeyPair() + } + + exception.message shouldBe "Only RSA keys are supported" + exception.cause should beInstanceOf() + } + + @Test + fun `Private key should be returned`() { + val keyPair = rsaKeyPair.private.encoded.deserialiseKeyPair() + + keyPair.private shouldBe rsaKeyPair.private + } + + @Test + fun `Public key should be returned`() { + val keyPair = rsaKeyPair.private.encoded.deserialiseKeyPair() + + keyPair.public shouldBe rsaKeyPair.public + } + } + + private fun generateDsaKeyPair(): KeyPair { + val keyGen = KeyPairGenerator.getInstance("DSA", BC_PROVIDER) + keyGen.initialize(1024) + return keyGen.generateKeyPair() + } } From b21c4772779305577840f4ed613d60ddb158120f Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Sun, 24 Sep 2023 16:07:13 +0100 Subject: [PATCH 14/20] add missing `@Throws` --- .../tech/relaycorp/letro/server/messages/AccountCreation.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt index a4e8f103..e83bfd57 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt @@ -26,6 +26,7 @@ class AccountCreation( val assignedUserId: String, val veraidBundle: ByteArray, ) { + @Throws(InvalidAccountCreationException::class) suspend fun validate(memberPublicKey: PublicKey) { val (bundle, bundleMember) = verifyBundle() @@ -48,6 +49,7 @@ class AccountCreation( } } + @Throws(InvalidAccountCreationException::class) private suspend fun verifyBundle(): Pair { val bundle = try { MemberIdBundle.deserialise(veraidBundle) From 809e2345bc4374895dfdd171b884cd90466d3d38 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 25 Sep 2023 13:37:36 +0100 Subject: [PATCH 15/20] `String.parseLocale()`: Add test for empty string --- .../test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt index f12cd8b8..2cc3c803 100644 --- a/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt @@ -66,6 +66,11 @@ class LocaleTest { fun `Language should be lower cased for consistency with Android`() { "EN-gb".parseLocale().language shouldBe "en" } + + @Test + fun `Empty string should be allowed`() { + "".parseLocale().language shouldBe "" + } } @Nested From 3ad81c7c0192879b25c76ecb037356e3c6cfbe80 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 25 Sep 2023 14:17:25 +0100 Subject: [PATCH 16/20] Use lazy locale in `RegistrationDomainProvider` --- .../onboarding/registration/RegistrationDomainProvider.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt index 5965538c..5ed1fc72 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/RegistrationDomainProvider.kt @@ -15,8 +15,7 @@ class RegistrationDomainProviderImpl @Inject constructor() : RegistrationDomainP } private val lazyDomain: String by lazy { - val locale = Locale.getDefault() - DOMAIN_BY_LOCALE[locale.toString()] ?: FALLBACK_DOMAIN + DOMAIN_BY_LOCALE[lazyDomainLocale.toString()] ?: FALLBACK_DOMAIN } override fun getDomain(): String { From 31043a75fdb84c601c8cd0318b4beab813773dc5 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 25 Sep 2023 14:20:28 +0100 Subject: [PATCH 17/20] AccountRepository: Rename updateAccount and pass Account instance --- .../account/storage/AccountRepository.kt | 24 ++++++++++--------- .../registration/AccountCreationProcessor.kt | 4 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt index b438aa8a..b4c4dadb 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt @@ -28,7 +28,7 @@ interface AccountRepository { locale: Locale, ): Account? - suspend fun completeRegistration(id: Long, veraidId: String, veraidBundle: ByteArray) + suspend fun updateAccount(account: Account, veraidId: String, veraidBundle: ByteArray) } class AccountRepositoryImpl @Inject constructor( @@ -80,15 +80,17 @@ class AccountRepositoryImpl @Inject constructor( locale = locale.normaliseString(), ) - override suspend fun completeRegistration(id: Long, veraidId: String, veraidBundle: ByteArray) { - accountDao.getById(id)?.let { - accountDao.update( - it.copy( - veraidId = veraidId, - veraidMemberBundle = veraidBundle, - isCreated = true, - ), - ) - } + override suspend fun updateAccount( + account: Account, + veraidId: String, + veraidBundle: ByteArray, + ) { + accountDao.update( + account.copy( + veraidId = veraidId, + veraidMemberBundle = veraidBundle, + isCreated = true, + ), + ) } } diff --git a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt index c3d58eb1..25aaf944 100644 --- a/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt +++ b/app/src/main/java/tech/relaycorp/letro/onboarding/registration/AccountCreationProcessor.kt @@ -43,8 +43,8 @@ class AccountCreationProcessorImpl @Inject constructor( return } - accountRepository.completeRegistration( - account.id, + accountRepository.updateAccount( + account, accountCreation.assignedUserId, accountCreation.veraidBundle, ) From cd089cdf920a697c0ae65761225447516f83d857 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 25 Sep 2023 14:22:01 +0100 Subject: [PATCH 18/20] AccountRequest: Remove duplicated code --- .../letro/server/messages/AccountRequest.kt | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) 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 index 4692c09c..3883898d 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt @@ -6,6 +6,7 @@ import org.bouncycastle.asn1.DERVisibleString import tech.relaycorp.letro.utils.asn1.ASN1Utils import tech.relaycorp.letro.utils.crypto.RSASigning import tech.relaycorp.letro.utils.crypto.spkiEncode +import tech.relaycorp.letro.utils.i18n.normaliseString import java.security.PrivateKey import java.security.PublicKey import java.util.Locale @@ -33,16 +34,5 @@ class AccountRequest( return ASN1Utils.serializeSequence(listOf(requestEncoded, DERBitString(signature)), false) } - private fun encodeLocale(): 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) - } + private fun encodeLocale() = DERVisibleString(locale.normaliseString()) } From 9cc2162fa2b22222a2da0565e3c299eaea59c336 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 25 Sep 2023 14:27:44 +0100 Subject: [PATCH 19/20] Make `parseLocale()` a standalone function --- .../letro/server/messages/AccountCreation.kt | 2 +- .../tech/relaycorp/letro/utils/i18n/Locale.kt | 6 ++++-- .../relaycorp/letro/utils/i18n/LocaleTest.kt | 16 ++++++++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt index e83bfd57..7b34523e 100644 --- a/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt @@ -132,7 +132,7 @@ class AccountCreation( exc, ) } - return localeEncoded.string.parseLocale() + return parseLocale(localeEncoded.string) } private fun decodeAssignedUserId(assignedUserIdTagged: ASN1Encodable): String { diff --git a/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt b/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt index 6e7f2ec7..64cb4244 100644 --- a/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt +++ b/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt @@ -1,3 +1,5 @@ +@file:JvmName("Locale") + package tech.relaycorp.letro.utils.i18n import java.util.Locale @@ -14,8 +16,8 @@ fun Locale.normaliseString(): String { } } -fun String.parseLocale(): Locale { - val localeParts = split("-") +fun parseLocale(localeCode: String): Locale { + val localeParts = localeCode.split("-") val languageCode = localeParts[0].lowercase() val countryCode = localeParts.getOrNull(1)?.uppercase() ?: "" val variantCode = localeParts.getOrNull(2) ?: "" diff --git a/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt index 2cc3c803..5735ab61 100644 --- a/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt +++ b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt @@ -59,17 +59,17 @@ class LocaleTest { inner class LanguageCode { @Test fun `Language should be decoded`() { - localeString.parseLocale().language shouldBe LOCALE.language + parseLocale(localeString).language shouldBe LOCALE.language } @Test fun `Language should be lower cased for consistency with Android`() { - "EN-gb".parseLocale().language shouldBe "en" + parseLocale("EN-gb").language shouldBe "en" } @Test fun `Empty string should be allowed`() { - "".parseLocale().language shouldBe "" + parseLocale("").language shouldBe "" } } @@ -77,17 +77,17 @@ class LocaleTest { inner class CountryCode { @Test fun `Should be decoded if present`() { - localeString.parseLocale().country shouldBe LOCALE.country + parseLocale(localeString).country shouldBe LOCALE.country } @Test fun `Should be upper cased for consistency with Android`() { - "en-gb".parseLocale().country shouldBe "GB" + parseLocale("en-gb").country shouldBe "GB" } @Test fun `Should be absent if not present in string`() { - "en".parseLocale().country shouldBe "" + parseLocale("en").country shouldBe "" } } @@ -95,12 +95,12 @@ class LocaleTest { inner class VariantCode { @Test fun `Should be decoded if present`() { - "en-gb-oxford".parseLocale().variant shouldBe "oxford" + parseLocale("en-gb-oxford").variant shouldBe "oxford" } @Test fun `Should be absent if not present in string`() { - "en-gb".parseLocale().variant shouldBe "" + parseLocale("en-gb").variant shouldBe "" } } } From 7177bdcf3624803778cb3d5551296c3f012e0f4d Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 25 Sep 2023 14:33:42 +0100 Subject: [PATCH 20/20] Rename `Account.locale` to `normalisedLocale` --- .../tech.relaycorp.letro.storage.LetroDatabase/1.json | 10 +++++----- .../java/tech/relaycorp/letro/account/model/Account.kt | 6 +++--- .../tech/relaycorp/letro/account/storage/AccountDao.kt | 2 +- .../letro/account/storage/AccountRepository.kt | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index 2994d020..122aafb8 100644 --- a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json +++ b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "dc43f5c1432dfa4a804e8ab2f5182994", + "identityHash": "5f2174158254547dddd8483c68da34b4", "entities": [ { "tableName": "account", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `veraidId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `locale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `veraidPrivateKey` BLOB NOT NULL, `veraidMemberBundle` BLOB, `isCreated` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `veraidId` TEXT NOT NULL, `requestedUserName` TEXT NOT NULL, `normalisedLocale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `veraidPrivateKey` BLOB NOT NULL, `veraidMemberBundle` BLOB, `isCreated` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -27,8 +27,8 @@ "notNull": true }, { - "fieldPath": "locale", - "columnName": "locale", + "fieldPath": "normalisedLocale", + "columnName": "normalisedLocale", "affinity": "TEXT", "notNull": true }, @@ -286,7 +286,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dc43f5c1432dfa4a804e8ab2f5182994')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f2174158254547dddd8483c68da34b4')" ] } } \ No newline at end of file diff --git a/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt b/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt index f546fae1..5b90f745 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/model/Account.kt @@ -15,7 +15,7 @@ data class Account( val id: Long = 0L, val veraidId: String, val requestedUserName: String, - val locale: String, + val normalisedLocale: String, val isCurrent: Boolean, // TODO: Encrypt key when integrating VeraId (https://relaycorp.atlassian.net/browse/LTR-55) val veraidPrivateKey: ByteArray, @@ -31,7 +31,7 @@ data class Account( if (id != other.id) return false if (veraidId != other.veraidId) return false if (requestedUserName != other.requestedUserName) return false - if (locale != other.locale) return false + if (normalisedLocale != other.normalisedLocale) return false if (isCurrent != other.isCurrent) return false if (!veraidPrivateKey.contentEquals(other.veraidPrivateKey)) return false if (veraidMemberBundle != null) { @@ -47,7 +47,7 @@ data class Account( var result = id.hashCode() result = 31 * result + veraidId.hashCode() result = 31 * result + requestedUserName.hashCode() - result = 31 * result + locale.hashCode() + result = 31 * result + normalisedLocale.hashCode() result = 31 * result + isCurrent.hashCode() result = 31 * result + veraidPrivateKey.contentHashCode() result = 31 * result + (veraidMemberBundle?.contentHashCode() ?: 0) diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt index f495c9f2..0ffa5e65 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountDao.kt @@ -23,6 +23,6 @@ interface AccountDao { @Query("SELECT * FROM $TABLE_NAME_ACCOUNT WHERE id=:id") suspend fun getById(id: Long): Account? - @Query("SELECT * FROM $TABLE_NAME_ACCOUNT WHERE requestedUserName=:requestedUserName AND locale=:locale") + @Query("SELECT * FROM $TABLE_NAME_ACCOUNT WHERE requestedUserName=:requestedUserName AND normalisedLocale=:locale") suspend fun getByRequestParams(requestedUserName: String, locale: String): Account? } diff --git a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt index b4c4dadb..a667ec60 100644 --- a/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt +++ b/app/src/main/java/tech/relaycorp/letro/account/storage/AccountRepository.kt @@ -67,7 +67,7 @@ class AccountRepositoryImpl @Inject constructor( Account( veraidId = "$requestedUserName@$domainName", requestedUserName = requestedUserName, - locale = locale.normaliseString(), + normalisedLocale = locale.normaliseString(), veraidPrivateKey = veraidPrivateKey.encoded, isCurrent = true, ),