diff --git a/app/build.gradle b/app/build.gradle index 08a1fdb9..b8f412cc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -56,6 +56,11 @@ android { lintConfig file('lint.xml') warningsAsErrors true } + testOptions { + unitTests.all { + useJUnitPlatform() + } + } } dependencies { @@ -65,8 +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.23' + def bouncy_castle_version = "1.70" + 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') @@ -99,13 +110,19 @@ dependencies { // Testing - testImplementation 'junit:junit:4.13.2' + 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' 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' + testImplementation "io.mockk:mockk:1.13.7" + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutinesVersion") } // Allow references to generated code diff --git a/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json b/app/schemas/tech.relaycorp.letro.storage.LetroDatabase/1.json index aa241231..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": "d80c574c6882c1c80c0f103b6a6ec580", + "identityHash": "5f2174158254547dddd8483c68da34b4", "entities": [ { "tableName": "account", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `veraId` 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, `normalisedLocale` TEXT NOT NULL, `isCurrent` INTEGER NOT NULL, `veraidPrivateKey` BLOB NOT NULL, `veraidMemberBundle` BLOB, `isCreated` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -15,8 +15,20 @@ "notNull": true }, { - "fieldPath": "veraId", - "columnName": "veraId", + "fieldPath": "veraidId", + "columnName": "veraidId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestedUserName", + "columnName": "requestedUserName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalisedLocale", + "columnName": "normalisedLocale", "affinity": "TEXT", "notNull": true }, @@ -26,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", @@ -41,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", @@ -119,7 +143,7 @@ "ownerVeraId" ], "referencedColumns": [ - "veraId" + "veraidId" ] } ] @@ -262,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, 'd80c574c6882c1c80c0f103b6a6ec580')" + "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 3ac03097..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 @@ -8,12 +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 normalisedLocale: 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 (normalisedLocale != other.normalisedLocale) 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 + normalisedLocale.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..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 @@ -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 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 0bfa8da3..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 @@ -9,13 +9,26 @@ 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.security.PrivateKey +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, + veraidPrivateKey: PrivateKey, + ) - suspend fun updateAccountId(id: String, newId: String) + suspend fun getByRequest( + requestedUserName: String, + locale: Locale, + ): Account? + + suspend fun updateAccount(account: Account, veraidId: String, veraidBundle: ByteArray) } class AccountRepositoryImpl @Inject constructor( @@ -44,23 +57,40 @@ class AccountRepositoryImpl @Inject constructor( } } - override suspend fun createAccount(id: String) { + override suspend fun createAccount( + requestedUserName: String, + domainName: String, + locale: Locale, + veraidPrivateKey: PrivateKey, + ) { accountDao.insert( Account( - veraId = id, + veraidId = "$requestedUserName@$domainName", + requestedUserName = requestedUserName, + normalisedLocale = locale.normaliseString(), + veraidPrivateKey = veraidPrivateKey.encoded, isCurrent = true, ), ) } - override suspend fun updateAccountId(id: String, newId: String) { - accountDao.getByVeraId(id)?.let { - accountDao.update( - it.copy( - veraId = newId, - isCreated = true, - ), - ) - } + override suspend fun getByRequest(requestedUserName: String, locale: Locale): Account? = + accountDao.getByRequestParams( + requestedUserName = requestedUserName, + locale = locale.normaliseString(), + ) + + 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/awala/message/MessageType.kt b/app/src/main/java/tech/relaycorp/letro/awala/message/MessageType.kt index 80244e07..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 @@ -4,8 +4,8 @@ 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 AccountCreationCompleted : MessageType("application/vnd.relaycorp.letro.account-creation-completed-tmp") + object AccountCreationRequest : MessageType("application/vnd.relaycorp.letro.account-request") + 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 106356a0..a4ea8d0d 100644 --- a/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt +++ b/app/src/main/java/tech/relaycorp/letro/contacts/ManageContactViewModel.kt @@ -77,7 +77,7 @@ class ManageContactViewModel @Inject constructor( editingContact = contactToEdit _uiState.update { it.copy( - veraId = contactToEdit.contactVeraId, + veraidId = contactToEdit.contactVeraId, alias = contactToEdit.alias, isVeraIdInputEnabled = false, ) @@ -107,7 +107,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 }, ) } @@ -140,7 +140,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") @@ -195,7 +195,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, ), @@ -230,7 +230,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 3a4b224e..75818d2c 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 @@ -102,7 +102,7 @@ fun ManageContactScreen( } else { ActionTakingScreen( actionTakingScreenUIStateModel = ActionTakingScreenUIStateModel.PairingRequestSent( - boldPartOfMessage = uiState.veraId, + boldPartOfMessage = uiState.veraidId, onGotItClicked = { viewModel.onGotItClick() }, @@ -153,7 +153,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..25aaf944 --- /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.updateAccount( + account, + accountCreation.assignedUserId, + accountCreation.veraidBundle, + ) + logger.info("Completed account creation ($accountCreation)") + } +} 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..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 @@ -5,21 +5,33 @@ 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[lazyDomainLocale.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..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 @@ -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) + val keyPair = generateRSAKeyPair() + accountRepository.createAccount(requestedUserName, domainName, locale, keyPair.private) + + 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..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 @@ -12,7 +12,7 @@ import javax.inject.Inject @HiltViewModel class RegistrationViewModel @Inject constructor( private val registrationRepository: RegistrationRepository, - domainProvider: RegistrationDomainProvider, + private val domainProvider: RegistrationDomainProvider, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -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/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 new file mode 100644 index 00000000..7b34523e --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountCreation.kt @@ -0,0 +1,162 @@ +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.normaliseString +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 + +/** + * 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, +) { + @Throws(InvalidAccountCreationException::class) + 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", + ) + } + } + + @Throws(InvalidAccountCreationException::class) + 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) + } + + 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) + } catch (exc: ASN1Exception) { + throw InvalidAccountCreationException( + "AccountCreation should be a DER-encoded sequence", + exc, + ) + } + if (accountCreationSequence.size() < 4) { + throw InvalidAccountCreationException( + "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 InvalidAccountCreationException( + "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 InvalidAccountCreationException( + "AccountCreation locale should be a DER-encoded VisibleString", + exc, + ) + } + return parseLocale(localeEncoded.string) + } + + private fun decodeAssignedUserId(assignedUserIdTagged: ASN1Encodable): String { + val assignedUserIdEncoded = try { + ASN1UTF8String.getInstance(assignedUserIdTagged as ASN1TaggedObject, false) + } catch (exc: RuntimeException) { + throw InvalidAccountCreationException( + "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 InvalidAccountCreationException( + "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 new file mode 100644 index 00000000..3883898d --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/server/messages/AccountRequest.kt @@ -0,0 +1,38 @@ +package tech.relaycorp.letro.server.messages + +import org.bouncycastle.asn1.DERBitString +import org.bouncycastle.asn1.DERUTF8String +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 + +/** + * 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, + val veraidMemberPublicKey: PublicKey, +) { + fun serialise(veraidMemberPrivateKey: PrivateKey): ByteArray { + val requestEncoded = ASN1Utils.makeSequence( + listOf( + DERUTF8String(userName), + encodeLocale(), + veraidMemberPublicKey.spkiEncode(), + ), + explicitTagging = false, + ) + val signature = RSASigning.sign(requestEncoded.encoded, veraidMemberPrivateKey) + return ASN1Utils.serializeSequence(listOf(requestEncoded, DERBitString(signature)), false) + } + + private fun encodeLocale() = DERVisibleString(locale.normaliseString()) +} 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/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/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..b8255353 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/asn1/ASN1Utils.kt @@ -0,0 +1,75 @@ +package tech.relaycorp.letro.utils.asn1 + +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 +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 -> + 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/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..a3cc72e5 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/crypto/KeyEncoding.kt @@ -0,0 +1,69 @@ +@file:JvmName("KeyEncoding") + +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.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, + 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) +} + +@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/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/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..64cb4244 --- /dev/null +++ b/app/src/main/java/tech/relaycorp/letro/utils/i18n/Locale.kt @@ -0,0 +1,25 @@ +@file:JvmName("Locale") + +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 parseLocale(localeCode: String): Locale { + val localeParts = localeCode.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/main/res/raw/server_connection_params.der b/app/src/main/res/raw/server_connection_params.der index 64c03948..77174f0f 100644 Binary files a/app/src/main/res/raw/server_connection_params.der and b/app/src/main/res/raw/server_connection_params.der differ 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/server/messages/AccountCreationTest.kt b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt new file mode 100644 index 00000000..e9c9c297 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountCreationTest.kt @@ -0,0 +1,390 @@ +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 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 = VERAID_USER_NAME + val locale = Locale("EN", "GB") + val assignedUserId = VERAID_MEMBER_ID + val veraidBundle = "the bundle".toByteArray() + + @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 { + 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 + } + } + } + + @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( + 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 new file mode 100644 index 00000000..a893f8bd --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/server/messages/AccountRequestTest.kt @@ -0,0 +1,147 @@ +package tech.relaycorp.letro.server.messages + +import io.kotest.matchers.shouldBe +import org.bouncycastle.asn1.ASN1BitString +import org.bouncycastle.asn1.ASN1Sequence +import org.bouncycastle.asn1.ASN1TaggedObject +import org.bouncycastle.asn1.ASN1UTF8String +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.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 +import tech.relaycorp.letro.utils.i18n.normaliseString +import java.util.Locale + +class AccountRequestTest { + val userName = VERAID_USER_NAME + val locale = Locale("EN", "GB") + + val keyPair = VERAID_MEMBER_KEY_PAIR + + @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 = 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 + } + + @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 + } + } + + @Test + fun `Should serialise the locale as a VisibleString`() { + val request = AccountRequest( + userName = userName, + locale = locale, + veraidMemberPublicKey = keyPair.public, + ) + + val serialisation = request.serialise(keyPair.private) + + 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 = userName, + 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 shouldBe keyPair.public.spkiEncode() + } + + 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 = userName, + 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 = userName, + 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/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() 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..f2d17ec7 --- /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 + } + } +} 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..e244240d --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/utils/crypto/KeyEncodingTest.kt @@ -0,0 +1,126 @@ +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 +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.KeyPair +import java.security.KeyPairGenerator +import java.security.spec.InvalidKeySpecException + +class KeyEncodingTest { + val rsaKeyPair = generateRSAKeyPair() + val dsaKeyPair = generateDsaKeyPair() + + @Nested + inner class PublicKeySpkiEncoding { + @Test + fun `Non-RSA keys should be refused`() { + val exception = shouldThrow { + dsaKeyPair.public.spkiEncode() + } + + exception.message shouldBe "Only RSA keys are supported" + } + + @Nested + inner class Algorithm { + @Test + fun `Algorithm should be RSA-PSS`() { + val encoding = rsaKeyPair.public.spkiEncode() + + encoding.algorithm.algorithm shouldBe PKCSObjectIdentifiers.id_RSASSA_PSS + } + + @Nested + inner class Params { + @Test + fun `Hash should be SHA-256`() { + val encoding = rsaKeyPair.public.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 = rsaKeyPair.public.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 = rsaKeyPair.public.spkiEncode() + + val parameters = encoding.algorithm.parameters as RSASSAPSSparams + parameters.saltLength.intValueExact() shouldBe 32 + } + + @Test + fun `Trailer field should be 1`() { + val encoding = rsaKeyPair.public.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 = rsaKeyPair.public.spkiEncode() + + 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() + } +} 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 + } +} 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..5735ab61 --- /dev/null +++ b/app/src/test/java/tech/relaycorp/letro/utils/i18n/LocaleTest.kt @@ -0,0 +1,107 @@ +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`() { + parseLocale(localeString).language shouldBe LOCALE.language + } + + @Test + fun `Language should be lower cased for consistency with Android`() { + parseLocale("EN-gb").language shouldBe "en" + } + + @Test + fun `Empty string should be allowed`() { + parseLocale("").language shouldBe "" + } + } + + @Nested + inner class CountryCode { + @Test + fun `Should be decoded if present`() { + parseLocale(localeString).country shouldBe LOCALE.country + } + + @Test + fun `Should be upper cased for consistency with Android`() { + parseLocale("en-gb").country shouldBe "GB" + } + + @Test + fun `Should be absent if not present in string`() { + parseLocale("en").country shouldBe "" + } + } + + @Nested + inner class VariantCode { + @Test + fun `Should be decoded if present`() { + parseLocale("en-gb-oxford").variant shouldBe "oxford" + } + + @Test + fun `Should be absent if not present in string`() { + parseLocale("en-gb").variant shouldBe "" + } + } + } +}