From 3725bc51c2eb9a623a1c1356b5a23615c3440fc8 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 18 Sep 2023 15:49:49 +0100 Subject: [PATCH] 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 "" + } + } + } +}