From 976afb3b4a6ef05802079d3cbf04cde8d799c886 Mon Sep 17 00:00:00 2001 From: Ian Bird Date: Mon, 19 Feb 2024 15:41:59 +0000 Subject: [PATCH] Implement support for Client Side Token Generation --- dev-app/src/main/AndroidManifest.xml | 3 +- .../com/uid2/dev/network/AppUID2Client.kt | 2 +- .../dev/network/AppUID2ClientException.kt | 4 +- .../main/java/com/uid2/dev/ui/MainScreen.kt | 63 ++- .../com/uid2/dev/ui/MainScreenViewModel.kt | 64 ++- .../com/uid2/dev/ui/views/ActionButtonView.kt | 6 +- ...EmailInputView.kt => IdentityInputView.kt} | 23 +- .../com/uid2/dev/ui/views/UserIdentityView.kt | 7 + .../java/com/uid2/dev/utils/ByteArrayEx.kt | 9 + dev-app/src/main/res/values/strings.xml | 9 +- sdk/src/main/java/com/uid2/UID2Client.kt | 147 ++++++- sdk/src/main/java/com/uid2/UID2Exception.kt | 16 +- sdk/src/main/java/com/uid2/UID2Manager.kt | 91 ++++- .../java/com/uid2/data/IdentityRequest.kt | 64 +++ .../java/com/uid2/extensions/ByteArrayEx.kt | 9 + .../main/java/com/uid2/extensions/StringEx.kt | 13 +- .../java/com/uid2/network/DataEnvelope.kt | 126 ++++-- .../java/com/uid2/network/RefreshResponse.kt | 21 +- .../{RefreshPackage.kt => ResponsePackage.kt} | 4 +- .../main/java/com/uid2/utils/InputUtils.kt | 163 ++++++++ sdk/src/main/java/com/uid2/utils/KeyUtils.kt | 92 +++++ sdk/src/main/java/com/uid2/utils/TimeUtils.kt | 19 +- sdk/src/test/java/android/util/Base64.kt | 5 + sdk/src/test/java/com/uid2/UID2ClientTest.kt | 379 +++++++++++++++--- sdk/src/test/java/com/uid2/UID2ManagerTest.kt | 97 ++++- .../java/com/uid2/data/IdentityRequestTest.kt | 66 +++ .../java/com/uid2/extensions/StringExTest.kt | 19 + .../java/com/uid2/network/DataEnvelopeTest.kt | 18 +- .../com/uid2/network/RefreshResponseTest.kt | 4 +- .../java/com/uid2/utils/InputUtilsTest.kt | 129 ++++++ 30 files changed, 1476 insertions(+), 196 deletions(-) rename dev-app/src/main/java/com/uid2/dev/ui/views/{EmailInputView.kt => IdentityInputView.kt} (65%) create mode 100644 dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt create mode 100644 sdk/src/main/java/com/uid2/data/IdentityRequest.kt create mode 100644 sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt rename sdk/src/main/java/com/uid2/network/{RefreshPackage.kt => ResponsePackage.kt} (63%) create mode 100644 sdk/src/main/java/com/uid2/utils/InputUtils.kt create mode 100644 sdk/src/main/java/com/uid2/utils/KeyUtils.kt create mode 100644 sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt create mode 100644 sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt diff --git a/dev-app/src/main/AndroidManifest.xml b/dev-app/src/main/AndroidManifest.xml index 068e6ca..d9659a6 100644 --- a/dev-app/src/main/AndroidManifest.xml +++ b/dev-app/src/main/AndroidManifest.xml @@ -13,7 +13,8 @@ android:supportsRtl="true" tools:targetApi="31"> - + + diff --git a/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt b/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt index 1efa625..ecb9973 100644 --- a/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt +++ b/dev-app/src/main/java/com/uid2/dev/network/AppUID2Client.kt @@ -142,7 +142,7 @@ class AppUID2Client( } private fun decryptResponse(key: String, data: String) = - DataEnvelope.decrypt(key, data, false)?.toString(Charsets.UTF_8) + DataEnvelope.decrypt(key, data, true)?.toString(Charsets.UTF_8) private fun Long.toByteArray() = ByteBuffer.allocate(Long.SIZE_BYTES).apply { order(ByteOrder.BIG_ENDIAN) diff --git a/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt b/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt index 2b3043b..ef2760b 100644 --- a/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt +++ b/dev-app/src/main/java/com/uid2/dev/network/AppUID2ClientException.kt @@ -1,6 +1,8 @@ package com.uid2.dev.network +import com.uid2.UID2Exception + /** * The exception thrown when an error occurred in the Development Application's UID2 Client. */ -class AppUID2ClientException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) +class AppUID2ClientException(message: String? = null, cause: Throwable? = null) : UID2Exception(message, cause) diff --git a/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt b/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt index 1a76e81..84b1266 100644 --- a/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt +++ b/dev-app/src/main/java/com/uid2/dev/ui/MainScreen.kt @@ -1,25 +1,44 @@ package com.uid2.dev.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.Checkbox +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Phone import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.uid2.dev.ui.MainScreenAction.EmailChanged +import com.uid2.dev.ui.MainScreenAction.PhoneChanged import com.uid2.dev.ui.MainScreenAction.RefreshButtonPressed import com.uid2.dev.ui.MainScreenAction.ResetButtonPressed import com.uid2.dev.ui.MainScreenState.ErrorState import com.uid2.dev.ui.MainScreenState.LoadingState import com.uid2.dev.ui.MainScreenState.UserUpdatedState import com.uid2.dev.ui.views.ActionButtonView -import com.uid2.dev.ui.views.EmailInputView import com.uid2.dev.ui.views.ErrorView +import com.uid2.dev.ui.views.IdentityInputView import com.uid2.dev.ui.views.LoadingView import com.uid2.dev.ui.views.UserIdentityView +import com.uid2.devapp.R +@OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(viewModel: MainScreenViewModel) { val viewState by viewModel.viewState.collectAsState() @@ -33,9 +52,49 @@ fun MainScreen(viewModel: MainScreenViewModel) { ) }, ) { padding -> + val checkedState = remember { mutableStateOf(true) } + Column(modifier = Modifier.padding(10.dp, 10.dp, 10.dp, 10.dp + padding.calculateBottomPadding())) { // The top of the View provides a way for the Email Address to be entered. - EmailInputView(Modifier, onEmailEntered = { viewModel.processAction(EmailChanged(it)) }) + IdentityInputView( + modifier = Modifier.padding(bottom = 6.dp), + label = stringResource(R.string.email), + icon = Icons.Default.Email, + onEntered = { viewModel.processAction(EmailChanged(it, checkedState.value)) }, + ) + + IdentityInputView( + label = stringResource(R.string.phone), + icon = Icons.Default.Phone, + onEntered = { viewModel.processAction(PhoneChanged(it, checkedState.value)) }, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Checkbox( + modifier = Modifier + .padding(vertical = 4.dp) + .padding(end = 4.dp), + checked = checkedState.value, + onCheckedChange = { checkedState.value = it }, + ) + } + + Text(text = stringResource(id = R.string.generate_client_side)) + } + + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + thickness = 2.dp, + color = Color.Black, + ) // Depending on the state of the View Model, we will switch in different content view. when (val state = viewState) { diff --git a/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt b/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt index 291b732..cbb6351 100644 --- a/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt +++ b/dev-app/src/main/java/com/uid2/dev/ui/MainScreenViewModel.kt @@ -4,7 +4,9 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.uid2.UID2Exception import com.uid2.UID2Manager +import com.uid2.UID2Manager.GenerateIdentityResult import com.uid2.UID2ManagerState.Established import com.uid2.UID2ManagerState.Expired import com.uid2.UID2ManagerState.Loading @@ -12,6 +14,7 @@ import com.uid2.UID2ManagerState.NoIdentity import com.uid2.UID2ManagerState.OptOut import com.uid2.UID2ManagerState.RefreshExpired import com.uid2.UID2ManagerState.Refreshed +import com.uid2.data.IdentityRequest import com.uid2.data.IdentityStatus import com.uid2.data.IdentityStatus.ESTABLISHED import com.uid2.data.IdentityStatus.EXPIRED @@ -22,8 +25,8 @@ import com.uid2.data.IdentityStatus.REFRESHED import com.uid2.data.IdentityStatus.REFRESH_EXPIRED import com.uid2.data.UID2Identity import com.uid2.dev.network.AppUID2Client -import com.uid2.dev.network.AppUID2ClientException import com.uid2.dev.network.RequestType.EMAIL +import com.uid2.dev.network.RequestType.PHONE import com.uid2.dev.ui.MainScreenState.ErrorState import com.uid2.dev.ui.MainScreenState.LoadingState import com.uid2.dev.ui.MainScreenState.UserUpdatedState @@ -33,7 +36,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed interface MainScreenAction : ViewModelAction { - data class EmailChanged(val address: String) : MainScreenAction + data class EmailChanged(val address: String, val clientSide: Boolean) : MainScreenAction + data class PhoneChanged(val number: String, val clientSide: Boolean) : MainScreenAction data object ResetButtonPressed : MainScreenAction data object RefreshButtonPressed : MainScreenAction } @@ -76,21 +80,60 @@ class MainScreenViewModel( override fun processAction(action: MainScreenAction) { Log.d(TAG, "Action: $action") + // If we are reported an error from generateIdentity's onResult callback, we will update our state to reflect it + val onGenerateResult: (GenerateIdentityResult) -> Unit = { result -> + when (result) { + is GenerateIdentityResult.Error -> viewModelScope.launch { _viewState.emit(ErrorState(result.ex)) } + else -> Unit + } + } + viewModelScope.launch { when (action) { is MainScreenAction.EmailChanged -> { + _viewState.emit(LoadingState) + try { - // For Development purposes, we are required to generate the initial Identity before then - // passing it onto the SDK to be managed. - _viewState.emit(LoadingState) - api.generateIdentity(action.address, EMAIL)?.let { - manager.setIdentity(it) + if (action.clientSide) { + // Generate the identity via Client Side Integration (client side token generation). + manager.generateIdentity( + IdentityRequest.Email(action.address), + SUBSCRIPTION_ID, + PUBLIC_KEY, + onGenerateResult, + ) + } else { + // We're going to generate the identity as if we've obtained it via a backend service. + api.generateIdentity(action.address, EMAIL)?.let { + manager.setIdentity(it) + } } - } catch (ex: AppUID2ClientException) { + } catch (ex: UID2Exception) { _viewState.emit(ErrorState(ex)) } } + is MainScreenAction.PhoneChanged -> { + _viewState.emit(LoadingState) + try { + if (action.clientSide) { + // Generate the identity via Client Side Integration (client side token generation). + manager.generateIdentity( + IdentityRequest.Phone(action.number), + SUBSCRIPTION_ID, + PUBLIC_KEY, + onGenerateResult, + ) + } else { + // We're going to generate the identity as if we've obtained it via a backend service. + api.generateIdentity(action.number, PHONE)?.let { + manager.setIdentity(it) + } + } + } catch (ex: UID2Exception) { + _viewState.emit(ErrorState(ex)) + } + } MainScreenAction.RefreshButtonPressed -> { manager.currentIdentity?.let { _viewState.emit(LoadingState) } manager.refreshIdentity() @@ -105,6 +148,11 @@ class MainScreenViewModel( private companion object { const val TAG = "MainScreenViewModel" + + const val SUBSCRIPTION_ID = "toPh8vgJgt" + + @Suppress("ktlint:standard:max-line-length") + const val PUBLIC_KEY = "UID2-X-I-MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKAbPfOz7u25g1fL6riU7p2eeqhjmpALPeYoyjvZmZ1xM2NM8UeOmDZmCIBnKyRZ97pz5bMCjrs38WM22O7LJuw==" } } diff --git a/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt b/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt index f04dcc0..f8bfc1c 100644 --- a/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt +++ b/dev-app/src/main/java/com/uid2/dev/ui/views/ActionButtonView.kt @@ -7,6 +7,8 @@ import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.uid2.devapp.R @Composable fun ActionButtonView(modifier: Modifier, onResetClick: () -> Unit, onRefreshClick: () -> Unit) { @@ -15,11 +17,11 @@ fun ActionButtonView(modifier: Modifier, onResetClick: () -> Unit, onRefreshClic horizontalArrangement = Arrangement.SpaceEvenly, ) { Button(onClick = onResetClick) { - Text("Reset") + Text(stringResource(id = R.string.action_reset)) } Button(onClick = onRefreshClick) { - Text("Manual Refresh") + Text(stringResource(id = R.string.action_refresh)) } } } diff --git a/dev-app/src/main/java/com/uid2/dev/ui/views/EmailInputView.kt b/dev-app/src/main/java/com/uid2/dev/ui/views/IdentityInputView.kt similarity index 65% rename from dev-app/src/main/java/com/uid2/dev/ui/views/EmailInputView.kt rename to dev-app/src/main/java/com/uid2/dev/ui/views/IdentityInputView.kt index f1f7b22..441bfa0 100644 --- a/dev-app/src/main/java/com/uid2/dev/ui/views/EmailInputView.kt +++ b/dev-app/src/main/java/com/uid2/dev/ui/views/IdentityInputView.kt @@ -9,50 +9,47 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextField -import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons.AutoMirrored.Filled import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Email import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import com.uid2.devapp.R @Composable -fun EmailInputView(modifier: Modifier, onEmailEntered: (String) -> Unit) { +fun IdentityInputView(modifier: Modifier = Modifier, label: String, icon: ImageVector, onEntered: (String) -> Unit) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - val emailAddress = remember { mutableStateOf(TextFieldValue()) } + val identityData = remember { mutableStateOf(TextFieldValue()) } TextField( - value = emailAddress.value, - onValueChange = { emailAddress.value = it }, - label = { Text(stringResource(R.string.email)) }, + value = identityData.value, + onValueChange = { identityData.value = it }, + label = { Text(label) }, singleLine = true, modifier = Modifier.weight(1f), leadingIcon = { Icon( - imageVector = Icons.Default.Email, - contentDescription = stringResource(R.string.email_icon_content_description), + imageVector = icon, + contentDescription = null, ) }, ) FloatingActionButton( - onClick = { onEmailEntered(emailAddress.value.text) }, + onClick = { onEntered(identityData.value.text) }, shape = CircleShape, backgroundColor = MaterialTheme.colors.primary, ) { Icon( imageVector = Filled.ArrowForward, - contentDescription = stringResource(R.string.email_submit_content_description), + contentDescription = null, tint = Color.White, ) } diff --git a/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt b/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt index 03e654e..d584ec9 100644 --- a/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt +++ b/dev-app/src/main/java/com/uid2/dev/ui/views/UserIdentityView.kt @@ -32,6 +32,13 @@ fun UserIdentityView(modifier: Modifier, identity: UID2Identity?, status: Identi .padding(0.dp, 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + Text( + modifier = Modifier.padding(bottom = 10.dp), + text = stringResource(id = R.string.current_identity), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + ) + identity?.let { UserIdentityParameter(stringResource(R.string.identity_advertising_token), identity.advertisingToken) UserIdentityParameter(stringResource(R.string.identity_refresh_token), identity.refreshToken) diff --git a/dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt b/dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt new file mode 100644 index 0000000..c413c40 --- /dev/null +++ b/dev-app/src/main/java/com/uid2/dev/utils/ByteArrayEx.kt @@ -0,0 +1,9 @@ +package com.uid2.dev.utils + +import android.util.Base64 + +/** + * Extension method to encode a ByteArray to a String. This uses the android.util version of Base64 to keep our minimum + * SDK low. + */ +fun ByteArray.encodeBase64(): String = Base64.encodeToString(this, Base64.NO_WRAP) diff --git a/dev-app/src/main/res/values/strings.xml b/dev-app/src/main/res/values/strings.xml index 0c30b50..71d8e9b 100644 --- a/dev-app/src/main/res/values/strings.xml +++ b/dev-app/src/main/res/values/strings.xml @@ -2,8 +2,10 @@ UID2 SDK Dev App Email - Email Icon - Submit Email + Phone Number + Client Side + + Current Identity Advertising Token Refresh Token @@ -21,4 +23,7 @@ Refresh Expired OptOut + Reset + Manual Refresh + diff --git a/sdk/src/main/java/com/uid2/UID2Client.kt b/sdk/src/main/java/com/uid2/UID2Client.kt index 530c1e7..d96a792 100644 --- a/sdk/src/main/java/com/uid2/UID2Client.kt +++ b/sdk/src/main/java/com/uid2/UID2Client.kt @@ -1,12 +1,17 @@ package com.uid2 +import com.uid2.data.IdentityRequest +import com.uid2.data.toPayload +import com.uid2.extensions.encodeBase64 import com.uid2.network.DataEnvelope import com.uid2.network.NetworkRequest import com.uid2.network.NetworkRequestType import com.uid2.network.NetworkSession -import com.uid2.network.RefreshPackage import com.uid2.network.RefreshResponse +import com.uid2.network.ResponsePackage +import com.uid2.utils.KeyUtils import com.uid2.utils.Logger +import com.uid2.utils.TimeUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -22,31 +27,115 @@ import java.net.URL internal class UID2Client( private val apiUrl: String, private val session: NetworkSession, + private val applicationId: String, + private val dataEnvelope: DataEnvelope = DataEnvelope, + private val timeUtils: TimeUtils = TimeUtils, + private val keyUtils: KeyUtils = KeyUtils, private val logger: Logger = Logger(), private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val paramsFactory: (Map) -> String = { params -> + JSONObject(params).toString() + }, ) { - // The refresh endpoint is built from the given API root, along with our known refresh path appended. If the - // consumer has incorrectly configured the SDK, it's possible this could be null. - private val apiRefreshUrl: URL? by lazy { - runCatching { - URL( - URI(apiUrl) - .resolve(API_REFRESH_PATH) - .toString(), - ) - }.getOrNull() - } + // The endpoints are built from the given API root, along with our known path appended. If the consumer has + // incorrectly configured the SDK, it's possible this could be null. + private val apiGenerateUrl: URL? by lazy { getApiUrl(API_GENERATE_PATH) } + private val apiRefreshUrl: URL? by lazy { getApiUrl(API_REFRESH_PATH) } // We expect the Client to report a Version that is in the following format: Android-X.Y.Z private val clientVersion: String by lazy { "Android-${UID2.getVersion()}" } @Throws( InvalidApiUrlException::class, - RefreshTokenException::class, + CryptoException::class, + RequestFailureException::class, PayloadDecryptException::class, InvalidPayloadException::class, ) - suspend fun refreshIdentity(refreshToken: String, refreshResponseKey: String): RefreshPackage = + suspend fun generateIdentity( + identityRequest: IdentityRequest, + subscriptionId: String, + publicKey: String, + ): ResponsePackage = withContext(ioDispatcher) { + logger.i(TAG) { "Generating Identity" } + + // Check to make sure we have a valid endpoint to hit. + val url = apiGenerateUrl ?: run { + logger.e(TAG) { "Error determining identity generation API" } + throw InvalidApiUrlException() + } + + // Generate the required Server and Client keys. + val serverPublicKey = keyUtils.generateServerPublicKey(publicKey) + val clientKeyPair = keyUtils.generateKeyPair() + if (serverPublicKey == null || clientKeyPair == null) { + logger.e(TAG) { "Error generating server and client keys" } + throw CryptoException() + } + + // Generate our temporary shared secret. + val sharedSecret = keyUtils.generateSharedSecret(serverPublicKey, clientKeyPair) ?: run { + logger.e(TAG) { "Error generating temporary shared secret" } + throw CryptoException() + } + + val iv = keyUtils.generateIv(IV_LENGTH_BYTES) + val now = timeUtils.now() + val aad = keyUtils.generateAad(now, applicationId) + + // Build and encrypt the payload containing the identity generation request. + val payload = identityRequest.toPayload() + val encryptedPayload = dataEnvelope.encrypt(sharedSecret, payload, iv, aad.toByteArray()) ?: run { + logger.e(TAG) { "Error encrypting payload" } + throw CryptoException() + } + + // Build the request to generate the token. + val request = NetworkRequest( + NetworkRequestType.POST, + mapOf( + HEADER_CLIENT_VERSION to clientVersion, + ), + paramsFactory( + mapOf( + "payload" to encryptedPayload.encodeBase64(), + "iv" to iv.encodeBase64(), + "public_key" to clientKeyPair.public.encoded.encodeBase64(), + "timestamp" to now.toString(), + "subscription_id" to subscriptionId, + "app_name" to applicationId, + ), + ), + ) + + // Attempt to make the request via the provided NetworkSession. + val response = session.loadData(url, request) + if (response.code != HttpURLConnection.HTTP_OK) { + logger.e(TAG) { "Client details failure: ${response.code}" } + throw RequestFailureException(response.code) + } + + // The response should be an encrypted payload. Let's attempt to decrypt it using the key we were provided. + val envelope = dataEnvelope.decrypt(sharedSecret.encoded, response.data, false) ?: run { + logger.e(TAG) { "Error decrypting response from client details" } + throw PayloadDecryptException() + } + + // The decrypted payload should be JSON which we can parse. + val generateResponse = RefreshResponse.fromJson(JSONObject(String(envelope, Charsets.UTF_8))) + return@withContext generateResponse?.toResponsePackage(false) ?: run { + logger.e(TAG) { "Error parsing response from client details" } + throw InvalidPayloadException() + } + } + + @Throws( + InvalidApiUrlException::class, + RequestFailureException::class, + PayloadDecryptException::class, + InvalidPayloadException::class, + ) + suspend fun refreshIdentity(refreshToken: String, refreshResponseKey: String): ResponsePackage = withContext(ioDispatcher) { logger.i(TAG) { "Refreshing identity" } @@ -60,7 +149,7 @@ internal class UID2Client( val request = NetworkRequest( NetworkRequestType.POST, mapOf( - "X-UID2-Client-Version" to clientVersion, + HEADER_CLIENT_VERSION to clientVersion, "Content-Type" to "application/x-www-form-urlencoded", ), refreshToken, @@ -70,27 +159,49 @@ internal class UID2Client( val response = session.loadData(url, request) if (response.code != HttpURLConnection.HTTP_OK) { logger.e(TAG) { "Client details failure: ${response.code}" } - throw RefreshTokenException(response.code) + throw RequestFailureException(response.code) } // The response should be an encrypted payload. Let's attempt to decrypt it using the key we were provided. - val payload = DataEnvelope.decrypt(refreshResponseKey, response.data, true) ?: run { + val payload = dataEnvelope.decrypt(refreshResponseKey, response.data, false) ?: run { logger.e(TAG) { "Error decrypting response from client details" } throw PayloadDecryptException() } // The decrypted payload should be JSON which we can parse. val refreshResponse = RefreshResponse.fromJson(JSONObject(String(payload, Charsets.UTF_8))) - return@withContext refreshResponse?.toRefreshPackage() ?: run { + return@withContext refreshResponse?.toResponsePackage(true) ?: run { logger.e(TAG) { "Error parsing response from client details" } throw InvalidPayloadException() } } + /** + * Builds a [URL] for the configured API server with the given (relative) path. + */ + private fun getApiUrl(path: String): URL? { + return runCatching { + URL( + URI(apiUrl) + .resolve(path) + .toString(), + ) + }.getOrNull() + } + private companion object { const val TAG = "UID2Client" + // The relative path of the API's generate endpoint + const val API_GENERATE_PATH = "/v2/token/client-generate" + // The relative path of the API's refresh endpoint const val API_REFRESH_PATH = "/v2/token/refresh" + + // The header used to provide the client version. + const val HEADER_CLIENT_VERSION = "X-UID2-Client-Version" + + // The length, in bytes, of the IV used when generating an identity. + const val IV_LENGTH_BYTES = 12 } } diff --git a/sdk/src/main/java/com/uid2/UID2Exception.kt b/sdk/src/main/java/com/uid2/UID2Exception.kt index b9312bc..946dbcd 100644 --- a/sdk/src/main/java/com/uid2/UID2Exception.kt +++ b/sdk/src/main/java/com/uid2/UID2Exception.kt @@ -3,7 +3,7 @@ package com.uid2 /** * Base class for all custom exceptions reported by the UID2 SDK. */ -internal open class UID2Exception(message: String? = null, cause: Throwable? = null) : Exception(message, cause) +public open class UID2Exception(message: String? = null, cause: Throwable? = null) : Exception(message, cause) /** * The SDK has been initialized *after* it's been created. @@ -16,9 +16,19 @@ internal class InitializationException(message: String? = null) : UID2Exception( internal class InvalidApiUrlException : UID2Exception() /** - * The attempt to refresh the token/identity via the API failed. + * An attempt to generate one of the required keys (for token generation) failed. */ -internal class RefreshTokenException(val statusCode: Int) : UID2Exception() +internal class CryptoException : UID2Exception() + +/** + * The given input appears to be invalid. + */ +public class InputValidationException(description: String?) : UID2Exception(description) + +/** + * An attempt was made to the API that resulted in a failure. + */ +internal class RequestFailureException(val statusCode: Int) : UID2Exception() /** * The encrypted payload could not be decrypted successfully. diff --git a/sdk/src/main/java/com/uid2/UID2Manager.kt b/sdk/src/main/java/com/uid2/UID2Manager.kt index 1f30f4a..4f2b7fd 100644 --- a/sdk/src/main/java/com/uid2/UID2Manager.kt +++ b/sdk/src/main/java/com/uid2/UID2Manager.kt @@ -10,6 +10,9 @@ import com.uid2.UID2ManagerState.OptOut import com.uid2.UID2ManagerState.RefreshExpired import com.uid2.UID2ManagerState.Refreshed import com.uid2.data.IdentityPackage +import com.uid2.data.IdentityRequest +import com.uid2.data.IdentityRequest.Email +import com.uid2.data.IdentityRequest.Phone import com.uid2.data.IdentityStatus import com.uid2.data.IdentityStatus.ESTABLISHED import com.uid2.data.IdentityStatus.EXPIRED @@ -22,6 +25,7 @@ import com.uid2.data.UID2Identity import com.uid2.network.DefaultNetworkSession import com.uid2.network.NetworkSession import com.uid2.storage.StorageManager +import com.uid2.utils.InputUtils import com.uid2.utils.Logger import com.uid2.utils.TimeUtils import kotlinx.coroutines.CoroutineDispatcher @@ -83,6 +87,7 @@ public class UID2Manager internal constructor( private val client: UID2Client, private val storageManager: StorageManager, private val timeUtils: TimeUtils, + private val inputUtils: InputUtils, defaultDispatcher: CoroutineDispatcher, initialAutomaticRefreshEnabled: Boolean, private val logger: Logger, @@ -142,8 +147,8 @@ public class UID2Manager internal constructor( } /** - * Gets whether or not [UID2Manager] has a known [UID2Identity]. If not, a new identity should be generated and set - * via [setIdentity]. + * Gets whether or not [UID2Manager] has a known [UID2Identity]. If not, a new identity should be generated either + * via [generateIdentity] or generated externally and set via [setIdentity]. */ public fun hasIdentity(): Boolean = currentIdentity != null @@ -188,6 +193,74 @@ public class UID2Manager internal constructor( } } + /** + * Represents the result of a request to [generateIdentity]. + */ + public sealed class GenerateIdentityResult { + + /** + * The identity was generated successfully and the [UID2Manager] as updated. + */ + public data object Success : GenerateIdentityResult() + + /** + * The generation of the identity failed. + * + * @param ex The exception which caused the request to fail. + */ + public data class Error(public val ex: UID2Exception) : GenerateIdentityResult() + } + + /** + * Generates a new identity. + * + * Once set, assuming it's valid, it will be monitored so that we automatically refresh the token(s) when required. + * This will also be persisted locally, so that when the application re-launches, we reload this Identity. + * + * @param identityRequest The identify for which the [UID2Identity] is required for. + * @param subscriptionId The subscription id that was obtained when configuring your account. + * @param publicKey The public key that was obtained when configuring your account. + * + * @throws InputValidationException Thrown if the given [IdentityRequest] is not valid. For a + * [IdentityRequest.Phone] we expect the given number to conform to the ITU E.164 Standard + * (https://en.wikipedia.org/wiki/E.164). + */ + @Throws(InputValidationException::class) + public fun generateIdentity( + identityRequest: IdentityRequest, + subscriptionId: String, + publicKey: String, + onResult: (GenerateIdentityResult) -> Unit, + ): Unit = afterInitialized { + // Normalize any given input to validate it. + val request = when (identityRequest) { + is Email -> inputUtils.normalize(identityRequest) + is Phone -> inputUtils.normalize(identityRequest) + else -> identityRequest + } + + scope.launch { + try { + // Attempt to generate the new identity. + val identity = client.generateIdentity(request, subscriptionId, publicKey) + + // Cancel any in-flight refresh job that could be processing a previously set identity. + refreshJob?.cancel() + refreshJob = null + + // Update our identity. + validateAndSetIdentity(identity.identity, identity.status) + + // Report our result. + onResult(GenerateIdentityResult.Success) + } catch (ex: UID2Exception) { + // The identity generation failed, so we will not modify our current state and report this to the + // caller. + onResult(GenerateIdentityResult.Error(ex)) + } + } + } + /** * Sets the current Identity. * @@ -447,6 +520,8 @@ public class UID2Manager internal constructor( // The default API server. private const val UID2_API_URL_DEFAULT = "https://prod.uidapi.com" + private const val APPLICATION_ID_DEFAULT = "unknown" + private const val PACKAGE_NOT_AVAILABLE = "Identity not available" private const val PACKAGE_AD_TOKEN_NOT_AVAILABLE = "advertising_token is not available or is not valid" private const val PACKAGE_REFRESH_TOKEN_NOT_AVAILABLE = "refresh_token is not available or is not valid" @@ -465,6 +540,7 @@ public class UID2Manager internal constructor( private const val EXPIRATION_CHECK_TOLERANCE_MS = 50 private var serverUrl: String = UID2_API_URL_DEFAULT + private var applicationId: String = APPLICATION_ID_DEFAULT private var networkSession: NetworkSession = DefaultNetworkSession() private var storageManager: StorageManager? = null private var isLoggingEnabled: Boolean = false @@ -494,6 +570,7 @@ public class UID2Manager internal constructor( } this.serverUrl = serverUrl + this.applicationId = context.packageName this.networkSession = networkSession this.storageManager = StorageManager.getInstance(context.applicationContext) this.isLoggingEnabled = isLoggingEnabled @@ -517,12 +594,14 @@ public class UID2Manager internal constructor( return instance ?: UID2Manager( UID2Client( - serverUrl, - networkSession, - logger, + apiUrl = serverUrl, + session = networkSession, + applicationId = applicationId, + logger = logger, ), storage, - TimeUtils(), + TimeUtils, + InputUtils(), Dispatchers.Default, true, logger, diff --git a/sdk/src/main/java/com/uid2/data/IdentityRequest.kt b/sdk/src/main/java/com/uid2/data/IdentityRequest.kt new file mode 100644 index 0000000..1304b4a --- /dev/null +++ b/sdk/src/main/java/com/uid2/data/IdentityRequest.kt @@ -0,0 +1,64 @@ +package com.uid2.data + +import com.uid2.data.IdentityRequest.Email +import com.uid2.data.IdentityRequest.EmailHash +import com.uid2.data.IdentityRequest.Phone +import com.uid2.data.IdentityRequest.PhoneHash +import com.uid2.extensions.toSha256 +import org.json.JSONObject + +/** + * This class represents the different types of identity requests that are supported client side. + */ +public sealed class IdentityRequest(internal val data: String) { + + /** + * A raw email address. + */ + public data class Email(private var email: String) : IdentityRequest(email) + + /** + * A SHA-256 hashed email address. + */ + public data class EmailHash(private var hash: String) : IdentityRequest(hash) + + /** + * A raw telephone number. + */ + public data class Phone(private val phone: String) : IdentityRequest(phone) + + /** + * A SHA-256 hashed telephone number. + */ + public data class PhoneHash(private var hash: String) : IdentityRequest(hash) +} + +/** + * Extension method to convert the associated [IdentityRequest] into the expected payload. + * + * The payload should only contained a SHA-256 hashed representation of the data. If a raw email or telephone number has + * been provided, then we will has it ourselves when building the payload. + */ +internal fun IdentityRequest.toPayload(): String { + val payloadKey = when (this) { + is Email, is EmailHash -> PARAM_EMAIL_HASH + is Phone, is PhoneHash -> PARAM_PHONE_HASH + } + + val payloadValue = when (this) { + is Email -> data.toSha256() + is Phone -> data.toSha256() + else -> data + } + + return JSONObject().apply { + put(payloadKey, payloadValue) + + // If the identity has opted out, we will get an opt-out response. + put(PARAM_OPT_OUT_CHECK, 1) + }.toString() +} + +private const val PARAM_EMAIL_HASH = "email_hash" +private const val PARAM_PHONE_HASH = "phone_hash" +private const val PARAM_OPT_OUT_CHECK = "optout_check" diff --git a/sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt b/sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt new file mode 100644 index 0000000..e8cb0b8 --- /dev/null +++ b/sdk/src/main/java/com/uid2/extensions/ByteArrayEx.kt @@ -0,0 +1,9 @@ +package com.uid2.extensions + +import android.util.Base64 + +/** + * Extension method to encode a ByteArray to a String. This uses the android.util version of Base64 to keep our minimum + * SDK low. + */ +internal fun ByteArray.encodeBase64(): String = Base64.encodeToString(this, Base64.NO_WRAP) diff --git a/sdk/src/main/java/com/uid2/extensions/StringEx.kt b/sdk/src/main/java/com/uid2/extensions/StringEx.kt index 8dd6dc9..53fa76b 100644 --- a/sdk/src/main/java/com/uid2/extensions/StringEx.kt +++ b/sdk/src/main/java/com/uid2/extensions/StringEx.kt @@ -2,11 +2,12 @@ package com.uid2.extensions import android.util.Base64 import org.json.JSONObject +import java.security.MessageDigest /** * Extension method to decode a String base Base64. */ -internal fun String.decodeBase64(): ByteArray? = runCatching { Base64.decode(this, Base64.DEFAULT) }.getOrNull() +internal fun String.decodeBase64(): ByteArray? = runCatching { Base64.decode(this, Base64.NO_WRAP) }.getOrNull() /** * Extension to parse a given String as JSON and convert to a Map. If parsing fails, e.g. the JSON @@ -19,3 +20,13 @@ internal fun String.decodeJsonToMap(): Map? { it to json.get(it) }.toMap() } + +/** + * Extension method to hash a string (via SHA-256) and return the Base64 representation of it. + */ +internal fun String.toSha256(): String { + return MessageDigest + .getInstance("SHA-256") + .digest(toByteArray()) + .encodeBase64() +} diff --git a/sdk/src/main/java/com/uid2/network/DataEnvelope.kt b/sdk/src/main/java/com/uid2/network/DataEnvelope.kt index 1163d01..d0c5d1c 100644 --- a/sdk/src/main/java/com/uid2/network/DataEnvelope.kt +++ b/sdk/src/main/java/com/uid2/network/DataEnvelope.kt @@ -2,6 +2,7 @@ package com.uid2.network import com.uid2.extensions.decodeBase64 import javax.crypto.Cipher +import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec @@ -12,21 +13,17 @@ import javax.crypto.spec.SecretKeySpec * **See Also:** * [GitHub](https://github.com/IABTechLab/uid2docs/blob/main/api/v2/getting-started/gs-encryption-decryption.md) */ -public object DataEnvelope { - // The name and transformation of the encryption algorithm used. - private const val ALGORITHM_NAME = "AES" - private const val ALGORITHM_TRANSFORMATION = "AES/GCM/NoPadding" +public interface DataEnvelope { - // The length of the authentication tag, in bits. - private const val AUTHENTICATION_TAG_LENGTH_BITS = 128 - - // The length of the IV, in bytes. - private const val IV_LENGTH_BYTES = 12 - - // The number of bytes expected in the decoded payload that represents the timestamp and nonce (used in the original - // request.) - private const val PAYLOAD_TIMESTAMP_LENGTH_BYTES = 8 - private const val PAYLOAD_NONCE_LENGTH_BYTES = 8 + /** + * Encrypts the given data with the provided key. + * + * @param key The key, as represented by a [SecretKey], required to encrypt the given data. + * @param data The data to encrypt. + * @param iv The initialization vector. + * @param aad Any additional authentication data. + */ + public fun encrypt(key: SecretKey, data: String, iv: ByteArray, aad: ByteArray): ByteArray? /** * Decrypts the given data with the provided key. @@ -36,37 +33,90 @@ public object DataEnvelope { * * @param key The key, in Base64 format, required to decode the given data. * @param data The data, in Base64 format, that needs to be decoded. + * @param includesNonce If a nonce (and timestamp) is expected in the decrypted data. + * @return The unencrypted data. If this decryption fails, null is returned. + */ + public fun decrypt(key: String, data: String, includesNonce: Boolean): ByteArray? + + /** + * Decrypts the given data with the provided key. + * + * This relies on the format of the data matching that spec-ed in the API documentation. We assume that it's AES + * encrypted, and includes the IV in the first 12 bytes of the buffer. + * + * @param key The key, in bytes, required to decode the given data. + * @param data The data, in Base64 format, that needs to be decoded. + * @param includesNonce If a nonce (and timestamp) is expected in the decrypted data. * @return The unencrypted data. If this decryption fails, null is returned. */ - public fun decrypt(key: String, data: String, isRefresh: Boolean): ByteArray? { - // Attempt to decrypt the given data with the provided key. Both the key and data are expected to be in Base64 - // format. If this fails, then null will be returned. - var payload = decryptWithCipher(key.decodeBase64(), data.decodeBase64()) ?: return null - - // If we are not refreshing, we expect the decoded payload to include both Timestamp and Nonce values. - if (!isRefresh) { - payload = payload.copyOfRange( - PAYLOAD_TIMESTAMP_LENGTH_BYTES + PAYLOAD_NONCE_LENGTH_BYTES, - payload.size, - ) + public fun decrypt(key: ByteArray?, data: String, includesNonce: Boolean): ByteArray? + + public companion object Default : DataEnvelope { + override fun encrypt(key: SecretKey, data: String, iv: ByteArray, aad: ByteArray): ByteArray? { + return encryptWithCipher(key, data.toByteArray(), iv, aad) } - return payload - } + override fun decrypt(key: String, data: String, includesNonce: Boolean): ByteArray? { + return decrypt(key.decodeBase64(), data, includesNonce) + } + + override fun decrypt(key: ByteArray?, data: String, includesNonce: Boolean): ByteArray? { + // Attempt to decrypt the given data with the provided key. Both the key and data are expected to be in Base64 + // format. If this fails, then null will be returned. + var payload = decryptWithCipher(key, data.decodeBase64()) ?: return null + + // If a nonce (and timestamp) is included in the payload, we should remove them. + if (includesNonce) { + payload = payload.copyOfRange( + PAYLOAD_TIMESTAMP_LENGTH_BYTES + PAYLOAD_NONCE_LENGTH_BYTES, + payload.size, + ) + } + + return payload + } + + private fun encryptWithCipher(key: SecretKey, data: ByteArray, iv: ByteArray, aad: ByteArray): ByteArray? { + val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, iv) + + // Initialise the appropriate AES Cipher. + val cipher = Cipher.getInstance(ALGORITHM_TRANSFORMATION)?.apply { + init(Cipher.ENCRYPT_MODE, key, spec) + updateAAD(aad) + } ?: return null + + return cipher.doFinal(data) + } + + private fun decryptWithCipher(key: ByteArray?, data: ByteArray?): ByteArray? { + key ?: return null + data ?: return null + + val secret = SecretKeySpec(key, ALGORITHM_NAME) + val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, data, 0, IV_LENGTH_BYTES) + + // Initialise the appropriate AES Cipher. + val cipher = Cipher.getInstance(ALGORITHM_TRANSFORMATION)?.apply { + init(Cipher.DECRYPT_MODE, secret, spec) + } ?: return null + + // Decrypt the data, skipping the first 12 bytes since that contains our IV. + return cipher.doFinal(data, IV_LENGTH_BYTES, data.size - IV_LENGTH_BYTES) + } - private fun decryptWithCipher(key: ByteArray?, data: ByteArray?): ByteArray? { - key ?: return null - data ?: return null + // The name and transformation of the encryption algorithm used. + private const val ALGORITHM_NAME = "AES" + private const val ALGORITHM_TRANSFORMATION = "AES/GCM/NoPadding" - val secret = SecretKeySpec(key, ALGORITHM_NAME) - val spec = GCMParameterSpec(AUTHENTICATION_TAG_LENGTH_BITS, data, 0, IV_LENGTH_BYTES) + // The length of the authentication tag, in bits. + private const val AUTHENTICATION_TAG_LENGTH_BITS = 128 - // Initialise the appropriate AES Cipher. - val cipher = Cipher.getInstance(ALGORITHM_TRANSFORMATION)?.apply { - init(Cipher.DECRYPT_MODE, secret, spec) - } ?: return null + // The length of the IV, in bytes. + private const val IV_LENGTH_BYTES = 12 - // Decrypt the data, skipping the first 12 bytes since that contains our IV. - return cipher.doFinal(data, IV_LENGTH_BYTES, data.size - IV_LENGTH_BYTES) + // The number of bytes expected in the decoded payload that represents the timestamp and nonce (used in the + // original request.) + private const val PAYLOAD_TIMESTAMP_LENGTH_BYTES = 8 + private const val PAYLOAD_NONCE_LENGTH_BYTES = 8 } } diff --git a/sdk/src/main/java/com/uid2/network/RefreshResponse.kt b/sdk/src/main/java/com/uid2/network/RefreshResponse.kt index 5c94d4d..9fe9264 100644 --- a/sdk/src/main/java/com/uid2/network/RefreshResponse.kt +++ b/sdk/src/main/java/com/uid2/network/RefreshResponse.kt @@ -1,5 +1,6 @@ package com.uid2.network +import com.uid2.data.IdentityStatus.ESTABLISHED import com.uid2.data.IdentityStatus.OPT_OUT import com.uid2.data.IdentityStatus.REFRESHED import com.uid2.data.IdentityStatus.REFRESH_EXPIRED @@ -12,7 +13,7 @@ import org.json.JSONObject * This class defines the expected response from the Identity API when refreshing. The results could include a new * (refreshed) Identity, or represent a failure/error. * - * https://github.com/IABTechLab/uid2docs/blob/main/api/v2/endpoints/post-token-refresh.md#decrypted-json-response-format + * https://unifiedid.com/docs/endpoints/post-token-refresh */ internal data class RefreshResponse( val body: UID2Identity?, @@ -32,17 +33,23 @@ internal data class RefreshResponse( ; companion object { - fun forStatus(status: String) = Status.values().first { it.text == status } + fun forStatus(status: String) = entries.first { it.text == status } } } /** - * Converts the response into the equivalent RefreshPackage. + * Converts the response into the equivalent [ResponsePackage]. */ - fun toRefreshPackage(): RefreshPackage? = when (status) { - SUCCESS -> RefreshPackage(body, REFRESHED, "Identity refreshed") - Status.OPT_OUT -> RefreshPackage(null, OPT_OUT, "User opt out") - EXPIRED_TOKEN -> RefreshPackage(null, REFRESH_EXPIRED, "Refresh token expired") + fun toResponsePackage(isRefresh: Boolean): ResponsePackage? = when (status) { + SUCCESS -> { + if (isRefresh) { + ResponsePackage(body, REFRESHED, "Identity refreshed") + } else { + ResponsePackage(body, ESTABLISHED, "Identity established") + } + } + Status.OPT_OUT -> ResponsePackage(null, OPT_OUT, "User opt out") + EXPIRED_TOKEN -> ResponsePackage(null, REFRESH_EXPIRED, "Refresh token expired") else -> null } diff --git a/sdk/src/main/java/com/uid2/network/RefreshPackage.kt b/sdk/src/main/java/com/uid2/network/ResponsePackage.kt similarity index 63% rename from sdk/src/main/java/com/uid2/network/RefreshPackage.kt rename to sdk/src/main/java/com/uid2/network/ResponsePackage.kt index c24fc56..a8a9608 100644 --- a/sdk/src/main/java/com/uid2/network/RefreshPackage.kt +++ b/sdk/src/main/java/com/uid2/network/ResponsePackage.kt @@ -4,9 +4,9 @@ import com.uid2.data.IdentityStatus import com.uid2.data.UID2Identity /** - * The data available after attempting to refresh the Identity. + * The data available after attempting to generate or refresh the Identity. */ -internal data class RefreshPackage( +internal data class ResponsePackage( val identity: UID2Identity?, val status: IdentityStatus, val message: String, diff --git a/sdk/src/main/java/com/uid2/utils/InputUtils.kt b/sdk/src/main/java/com/uid2/utils/InputUtils.kt new file mode 100644 index 0000000..2b975d3 --- /dev/null +++ b/sdk/src/main/java/com/uid2/utils/InputUtils.kt @@ -0,0 +1,163 @@ +package com.uid2.utils + +import com.uid2.InputValidationException +import com.uid2.data.IdentityRequest +import com.uid2.utils.InputUtils.EmailParsingState.Starting +import com.uid2.utils.InputUtils.EmailParsingState.SubDomain +import java.util.Locale + +/** + * This class contains a set of methods that will attempt to normalize a given [IdentityRequest]. This is to validate + * what is provided to us to ensure it's in the expected format, before we attempt to consume it. + */ +internal class InputUtils { + + /** + * Attempts to normalize the given [IdentityRequest.Phone] + * + * @throws InputValidationException Thrown if the input could not be normalized. + */ + @Throws(InputValidationException::class) + fun normalize(request: IdentityRequest.Phone): IdentityRequest.Phone { + if (!isNormalized(request)) { + throw InputValidationException("Phone number is not normalized to ITU E.164 standard") + } + + return request + } + + /** + * Attempts to normalize the given [IdentityRequest.Email] + * + * @throws InputValidationException Thrown if the input could not be normalized. + */ + @Throws(InputValidationException::class) + fun normalize(request: IdentityRequest.Email): IdentityRequest.Email { + val normalized = normalizeEmail(request.data) + ?: throw InputValidationException("Invalid email address detected") + + return IdentityRequest.Email(normalized) + } + + /** + * Returns whether or not the given [IdentityRequest.Phone] has already been normalized. This will check against + * the ITU E.164 Standard (https://en.wikipedia.org/wiki/E.164). + */ + private fun isNormalized(request: IdentityRequest.Phone): Boolean { + val number = request.data + + // Firstly, let's check to make sure we have a non-empty string + if (number.isEmpty()) { + return false + } + + // The first character should be a '+'. + if (number[0] != '+') { + return false + } + + // Check to make sure that only digits are contained. + val allDigits = number.substring(1).all { isAsciiDigit(it) } + if (!allDigits) { + return false + } + + // The number of digits (excluding the '+') should be in the expected range. + val totalDigits = number.length - 1 + return !(totalDigits < MIN_PHONE_NUMBER_DIGITS || totalDigits> MAX_PHONE_NUMBER_DIGITS) + } + + /** + * Returns whether or not the given [String] contains only ASCII digits. + */ + private fun isAsciiDigit(digit: Char): Boolean { + return digit in '0'..'9' + } + + private enum class EmailParsingState { + Starting, + SubDomain, + } + + /** + * This code will attempt to normalize a given email address. It's been translated from a Java reference, and + * therefore been kept as close to the original implementation as possible (with some unused conditions removed). + * + * https://github.com/IABTechLab/uid2-operator/blob/a331b88bcb1d7a1a9f0128a7ca0ff4b1de6f0779/src/main/java/com/uid2/operator/service/InputUtil.java#L96 + */ + private fun normalizeEmail(email: String): String? { + val preSubDomain = StringBuilder() + val preSubDomainSpecialized = StringBuilder() + val subDomain = StringBuilder() + val subDomainWhiteSpace = StringBuilder() + + var parsingState = Starting + var inExtension = false + + // Let's start by converting the given address to lower case, before iterating over the individual characters. + val lower = email.lowercase(Locale.getDefault()) + lower.forEach { char -> + when (parsingState) { + Starting -> { + if (char == ' ') { + return@forEach + } else if (char == '@') { + parsingState = SubDomain + } else if (char == '.') { + preSubDomain.append(char) + } else if (char == '+') { + preSubDomain.append(char) + inExtension = true + } else { + preSubDomain.append(char) + if (!inExtension) { + preSubDomainSpecialized.append(char) + } + } + } + + SubDomain -> { + if (char == '@') { + return null + } else if (char == ' ') { + subDomainWhiteSpace.append(char) + return@forEach + } + + if (subDomainWhiteSpace.isNotEmpty()) { + subDomain.append(subDomainWhiteSpace.toString()) + subDomainWhiteSpace.clear() + } + + subDomain.append(char) + } + } + } + + // Verify that we've parsed the subdomain correctly. + if (subDomain.isEmpty()) { + return null + } + + // Verify that we've parsed the address part correctly. + val addressPartToUse = if (DOMAIN_GMAIL == subDomain.toString()) { + preSubDomainSpecialized + } else { + preSubDomain + } + + if (addressPartToUse.isEmpty()) { + return null + } + + // Build the normalized version of the email address. + return addressPartToUse.append('@').append(subDomain.toString()).toString() + } + + private companion object { + const val MIN_PHONE_NUMBER_DIGITS = 10 + const val MAX_PHONE_NUMBER_DIGITS = 15 + + const val DOMAIN_GMAIL = "gmail.com" + } +} diff --git a/sdk/src/main/java/com/uid2/utils/KeyUtils.kt b/sdk/src/main/java/com/uid2/utils/KeyUtils.kt new file mode 100644 index 0000000..fc8cd03 --- /dev/null +++ b/sdk/src/main/java/com/uid2/utils/KeyUtils.kt @@ -0,0 +1,92 @@ +package com.uid2.utils + +import com.uid2.extensions.decodeBase64 +import org.json.JSONArray +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PublicKey +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.KeyAgreement +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * An class containing utility methods for generating Security based keys, e.g. [PublicKey], [KeyPair] and [SecretKey]. + */ +internal interface KeyUtils { + /** + * Generates the additional authentication data required when generating an identity. + */ + fun generateAad(now: Long, applicationId: String): String + + /** + * Generates a new IV of the given length. + */ + fun generateIv(length: Int): ByteArray + + /** + * Generates the [PublicKey] that was provided by the UID2 API server. + */ + fun generateServerPublicKey(publicKey: String): PublicKey? + + /** + * Generates a new Public/Private [KeyPair]. + */ + fun generateKeyPair(): KeyPair? + + /** + * For a given [PublicKey] and [KeyPair], generates a [SecretKey]. + */ + fun generateSharedSecret(serverPublicKey: PublicKey, clientKeyPair: KeyPair): SecretKey? + + companion object Default : KeyUtils { + + override fun generateAad(now: Long, applicationId: String): String { + return JSONArray().apply { + put(now) + put(applicationId) + }.toString() + } + + override fun generateIv(length: Int): ByteArray { + return ByteArray(length).apply { random.nextBytes(this) } + } + + override fun generateServerPublicKey(publicKey: String): PublicKey? { + val serverPublicKeyBytes = + publicKey.substring(SERVER_PUBLIC_KEY_PREFIX_LENGTH).decodeBase64() ?: return null + + return KeyFactory.getInstance("EC") + .generatePublic( + X509EncodedKeySpec( + serverPublicKeyBytes, + ), + ) + } + + override fun generateKeyPair(): KeyPair? { + return runCatching { + KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.genKeyPair() + }.getOrNull() + } + + override fun generateSharedSecret(serverPublicKey: PublicKey, clientKeyPair: KeyPair): SecretKey? { + return runCatching { + val secretKey = KeyAgreement.getInstance("ECDH").apply { + init(clientKeyPair.private) + doPhase(serverPublicKey, true) + }.generateSecret() + + SecretKeySpec(secretKey, "AES") + }.getOrNull() + } + + private val random: SecureRandom by lazy { SecureRandom() } + private const val SERVER_PUBLIC_KEY_PREFIX_LENGTH = 9 + } +} diff --git a/sdk/src/main/java/com/uid2/utils/TimeUtils.kt b/sdk/src/main/java/com/uid2/utils/TimeUtils.kt index de38416..4d1b695 100644 --- a/sdk/src/main/java/com/uid2/utils/TimeUtils.kt +++ b/sdk/src/main/java/com/uid2/utils/TimeUtils.kt @@ -3,15 +3,28 @@ package com.uid2.utils /** * A class containing utility methods around the current time. */ -internal class TimeUtils { +internal interface TimeUtils { + + /** + * Returns the current epoch time (in milliseconds). + */ + fun now(): Long /** * Returns whether or not the given (epoch) time in milliseconds, is in the past. */ - fun hasExpired(expiryMs: Long) = expiryMs <= System.currentTimeMillis() + fun hasExpired(expiryMs: Long): Boolean /** * Returns the number of milliseconds difference between the given time and "now". */ - fun diffToNow(fromMs: Long) = fromMs - System.currentTimeMillis() + fun diffToNow(fromMs: Long): Long + + companion object Default : TimeUtils { + override fun now() = System.currentTimeMillis() + + override fun hasExpired(expiryMs: Long) = expiryMs <= System.currentTimeMillis() + + override fun diffToNow(fromMs: Long) = fromMs - System.currentTimeMillis() + } } diff --git a/sdk/src/test/java/android/util/Base64.kt b/sdk/src/test/java/android/util/Base64.kt index 03c84f1..b44dcdc 100644 --- a/sdk/src/test/java/android/util/Base64.kt +++ b/sdk/src/test/java/android/util/Base64.kt @@ -13,4 +13,9 @@ object Base64 { fun decode(str: String?, flags: Int): ByteArray { return Base64.getDecoder().decode(str) } + + @JvmStatic + fun encodeToString(input: ByteArray, flags: Int): String { + return Base64.getEncoder().encodeToString(input) + } } diff --git a/sdk/src/test/java/com/uid2/UID2ClientTest.kt b/sdk/src/test/java/com/uid2/UID2ClientTest.kt index 43ab5fa..865ead9 100644 --- a/sdk/src/test/java/com/uid2/UID2ClientTest.kt +++ b/sdk/src/test/java/com/uid2/UID2ClientTest.kt @@ -1,96 +1,253 @@ package com.uid2 +import com.uid2.data.IdentityRequest import com.uid2.data.IdentityStatus import com.uid2.data.TestData import com.uid2.data.UID2Identity +import com.uid2.network.DataEnvelope import com.uid2.network.NetworkRequest import com.uid2.network.NetworkResponse import com.uid2.network.NetworkSession +import com.uid2.utils.KeyUtils import com.uid2.utils.Logger +import com.uid2.utils.TimeUtils import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import java.security.KeyPair +import java.security.PublicKey @RunWith(MockitoJUnitRunner::class) class UID2ClientTest { + private val testDispatcher: TestDispatcher = StandardTestDispatcher() + private val networkSession: NetworkSession = mock() + private val packageName = "com.uid2.devapp" + private val dataEnvelope: DataEnvelope = mock() + private val keyUtils: KeyUtils = mock() + private val timeUtils: TimeUtils = mock() private val logger: Logger = mock() private val url = "https://test.dev" private val refreshToken = "RefreshToken" private val refreshKey = "RefreshKey" + private val keyPair: KeyPair = mock() + private val keyPairPublic: PublicKey = mock() + private val keyPairPublicEncoded = ByteArray(12) + + private val SUBSCRIPTION_ID = "subscription_id" + private val PUBLIC_KEY = "public_key" + + @Before + fun before() { + // By default, don't encrypt the data. Just convert it directly to a ByteArray. + whenever(dataEnvelope.encrypt(any(), any(), any(), any())).thenAnswer { + return@thenAnswer (it.arguments[1] as String).toByteArray() + } + + whenever(keyUtils.generateServerPublicKey(any())).thenReturn(mock()) + whenever(keyUtils.generateKeyPair()).thenReturn(keyPair) + whenever(keyUtils.generateSharedSecret(any(), any())).thenReturn(mock()) + whenever(keyUtils.generateIv(any())).thenAnswer { ByteArray(it.arguments[0] as Int) } + whenever(keyUtils.generateAad(any(), any())).thenReturn("") + + whenever(keyPairPublic.encoded).thenReturn(keyPairPublicEncoded) + whenever(keyPair.public).thenReturn(keyPairPublic) + + whenever(timeUtils.now()).thenReturn(0) + } + + //region generateIdentity + @Test - fun `test invalid api url`() { - val client = UID2Client( - "this is not a url", - networkSession, - logger, - ) + fun `test generate with invalid api url`() = runTest(testDispatcher) { + testInvalidClientApi { client -> + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } + } - // Verify that when we have configured the client with an invalid URL, that it throws the appropriate exception - // when we try to refresh the Identity. - assertThrows(InvalidApiUrlException::class.java) { - runBlocking { client.refreshIdentity(refreshToken, refreshKey) } + @Test + fun `test generate with public key failure`() = runTest(testDispatcher) { + val client = withClient() + + // Mock the KeyUtils to fail to generate the required PublicKey. + whenever(keyUtils.generateServerPublicKey(any())).thenReturn(null) + + // Verify the expected CryptoException is thrown. + assertThrows(CryptoException::class.java) { + runTest(testDispatcher) { + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } } } @Test - fun `test network failure`() { - val client = UID2Client( - url, - networkSession, - logger, - ) + fun `test generate with key pair failure`() = runTest(testDispatcher) { + val client = withClient() - // Configure the network session to report a failure. - whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(400)) + // Mock the KeyUtils to fail to generate the required KeyPair. + whenever(keyUtils.generateKeyPair()).thenReturn(null) - // Verify that when a network failure occurs, the appropriate exception is thrown. - assertThrows(RefreshTokenException::class.java) { - runBlocking { client.refreshIdentity(refreshToken, refreshKey) } + // Verify the expected CryptoException is thrown. + assertThrows(CryptoException::class.java) { + runTest(testDispatcher) { + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } } } @Test - fun `test invalid data failure`() { - val client = UID2Client( - url, - networkSession, - logger, - ) + fun `test generate with shared secret failure`() = runTest(testDispatcher) { + val client = withClient() - whenever(networkSession.loadData(any(), any())).thenReturn( - NetworkResponse(200, "This is not encrypted"), - ) + // Mock the KeyUtils to fail to generate the required SharedSecret. + whenever(keyUtils.generateSharedSecret(any(), any())).thenReturn(null) - // Verify that when an unexpected response is returned, the appropriate exception is thrown. + // Verify the expected CryptoException is thrown. + assertThrows(CryptoException::class.java) { + runTest(testDispatcher) { + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } + } + } + + @Test + fun `test generate with encryption failure`() = runTest(testDispatcher) { + val client = withClient() + + // Mock the DataEnvelope to fail to encrypt the given payload. + whenever(dataEnvelope.encrypt(any(), any(), any(), any())).thenReturn(null) + + // Verify the expected CryptoException is thrown. + assertThrows(CryptoException::class.java) { + runTest(testDispatcher) { + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } + } + } + + @Test + fun `test generate with network failure`() = runTest(testDispatcher) { + testNetworkFailure { client -> + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } + } + + @Test + fun `test generate with decryption failure`() = runTest(testDispatcher) { + val client = withClient() + + // Mock the DataEnvelope to fail to decrypt the given payload. + whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "somedata")) + whenever(dataEnvelope.decrypt(anyOrNull(), any(), any())).thenReturn(null) + + // Verify the expected CryptoException is thrown. assertThrows(PayloadDecryptException::class.java) { - runBlocking { client.refreshIdentity(refreshToken, refreshKey) } + runTest(testDispatcher) { + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } } } @Test - fun `test invalid data key`() { - val client = UID2Client( - url, - networkSession, - logger, + fun `test generate success`() = runTest(testDispatcher) { + val client = withClient() + + val unencrypted = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED) + whenever(dataEnvelope.decrypt(anyOrNull(), any(), any())).thenReturn( + unencrypted.toString().toByteArray(), ) + whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data")) - whenever(networkSession.loadData(any(), any())).thenReturn( - NetworkResponse(200, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED), + val response = client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, ) + assertNotNull(response) + + // Verify that the returned package has an identity that matches what we included in the body of the response. + val expectedIdentity = unencrypted.getJSONObject("body").let { + UID2Identity.fromJson(it) + } + assertEquals(expectedIdentity, response.identity) + } + + //endregion + + //region refreshIdentity + + @Test + fun `test refresh with invalid api url`() = runTest(testDispatcher) { + testInvalidClientApi { client -> + client.refreshIdentity(refreshToken, refreshKey) + } + } + + @Test + fun `test refresh with network failure`() = runTest(testDispatcher) { + testNetworkFailure { client -> + client.refreshIdentity(refreshToken, refreshKey) + } + } + + @Test + fun `test refresh with invalid data failure`() = runTest(testDispatcher) { + testInvalidNetworkResponse { client -> + client.refreshIdentity(refreshToken, refreshKey) + } + } + + @Test + fun `test refresh with invalid data key`() = runTest(testDispatcher) { + val client = withClient() + + whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(null) + whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data")) // Verify that when an unexpected response is returned, the appropriate exception is thrown. assertThrows(PayloadDecryptException::class.java) { @@ -99,41 +256,34 @@ class UID2ClientTest { } @Test - fun `test successful refresh`() = runBlocking { - val client = UID2Client( - url, - networkSession, - logger, - ) + fun `test successful refresh`() = runTest(testDispatcher) { + val client = withClient() - // Configure the network session to return a valid (encrypted) payload. - whenever(networkSession.loadData(any(), any())).thenReturn( - NetworkResponse(200, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED), - ) + val unencrypted = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED) + whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(unencrypted.toString().toByteArray()) + whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data")) // Verify that the payload was successfully decrypted, and parsed. val identity = client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY) assertEquals(IdentityStatus.REFRESHED, identity.status) // Verify that the returned package has an identity that matches what we included in the body of the response. - val expectedIdentity = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED).getJSONObject("body").let { + val expectedIdentity = unencrypted.getJSONObject("body").let { UID2Identity.fromJson(it) } assertEquals(expectedIdentity, identity.identity) } + //endregion + @Test - fun `test successful optout`() = runBlocking { - val client = UID2Client( - url, - networkSession, - logger, - ) + fun `test successful opt-out`() = runBlocking { + val client = withClient() - // Configure the network session to return a valid (encrypted) payload. - whenever(networkSession.loadData(any(), any())).thenReturn( - NetworkResponse(200, TestData.REFRESH_TOKEN_OPT_OUT_ENCRYPTED), - ) + // Configure the network session to return a valid payload. + val unencrypted = JSONObject(TestData.REFRESH_TOKEN_OPT_OUT_DECRYPTED) + whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn(unencrypted.toString().toByteArray()) + whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(200, "some data")) // Verify that the payload was successfully decrypted, and parsed. val identity = client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_OPT_OUT_KEY) @@ -142,27 +292,126 @@ class UID2ClientTest { } @Test - fun `test version info`() = runBlocking { + fun `test version info - generate identity`() { + testVersionInfo { client -> + val unencrypted = JSONObject(TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED) + whenever(dataEnvelope.decrypt(anyOrNull(), any(), any())).thenReturn( + unencrypted.toString().toByteArray(), + ) + + // Ask the Client to generate a new Identity, so it can make the appropriate request. + client.generateIdentity( + IdentityRequest.Email("test@test.com"), + SUBSCRIPTION_ID, + PUBLIC_KEY, + ) + } + } + + @Test + fun `test version info - refresh identity`() { + whenever(dataEnvelope.decrypt(any(), any(), any())).thenReturn( + TestData.REFRESH_TOKEN_SUCCESS_DECRYPTED.toByteArray(), + ) + + testVersionInfo { client -> + // Ask the Client to refresh the Identity, so it can make the appropriate request. + client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY) + } + } + + /** + * Helper function to test that the given callback will result in the correct exception when the [UID2Client] is + * configured with an invalid API URL. + */ + private fun testInvalidClientApi(callback: suspend (client: UID2Client) -> Unit) { val client = UID2Client( - url, + "this is not a url", networkSession, + packageName, + dataEnvelope, + timeUtils, + keyUtils, logger, ) + // Verify that when we have configured the client with an invalid URL, that it throws the appropriate exception + // when we try to fetch the client details. + assertThrows(InvalidApiUrlException::class.java) { + runTest(testDispatcher) { + callback(client) + } + } + } + + /** + * Helper function to test that a given callback will result in the correct exception when the [UID2Client] + * experiences a network failure. + */ + private fun testNetworkFailure(callback: suspend (client: UID2Client) -> Unit) { + val client = withClient() + + // Configure the network session to report a failure. + whenever(networkSession.loadData(any(), any())).thenReturn(NetworkResponse(400)) + + // Verify that when a network failure occurs, the appropriate exception is thrown. + assertThrows(RequestFailureException::class.java) { + runTest(testDispatcher) { + callback(client) + } + } + } + + /** + * Helper function to test that a given callback will result in the correct exception when the [UID2Client] + * receives an unexpected response. + */ + private fun testInvalidNetworkResponse(callback: suspend (client: UID2Client) -> Unit) { + val client = withClient() + + // Configure the network session to return an invalid response. + whenever(networkSession.loadData(any(), any())).thenReturn( + NetworkResponse(200, "This is not encrypted"), + ) + + // Verify that when an unexpected response is received, the appropriate exception is thrown. + assertThrows(PayloadDecryptException::class.java) { + runTest(testDispatcher) { + callback(client) + } + } + } + + /** + * Helper function to test whether the expected version headers are included in a specific request, with the + * request being invoked via a given callback. + */ + private fun testVersionInfo(callback: suspend (client: UID2Client) -> Unit) = runTest(testDispatcher) { + val client = withClient() + // Configure the network session to return a valid (encrypted) payload and allows us to capture the given // NetworkRequest. var networkRequest: NetworkRequest? = null whenever(networkSession.loadData(any(), any())).thenAnswer { networkRequest = it.arguments[1] as NetworkRequest? - return@thenAnswer NetworkResponse(200, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED) + return@thenAnswer NetworkResponse(200, "some data") } - // Ask the Client to refresh the Identity, so it can make the appropriate request. - client.refreshIdentity(refreshToken, TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY) + callback(client) // Verify that the Client included the expected Version header, and that it ended with our SDK version. val reportedVersion = networkRequest?.headers?.get("X-UID2-Client-Version") assertNotNull(reportedVersion) assertTrue(reportedVersion?.endsWith(UID2.getVersion()) == true) } + + private fun withClient() = UID2Client( + url, + networkSession, + packageName, + dataEnvelope, + timeUtils, + keyUtils, + logger, + ) } diff --git a/sdk/src/test/java/com/uid2/UID2ManagerTest.kt b/sdk/src/test/java/com/uid2/UID2ManagerTest.kt index e4e4b9d..3a636b3 100644 --- a/sdk/src/test/java/com/uid2/UID2ManagerTest.kt +++ b/sdk/src/test/java/com/uid2/UID2ManagerTest.kt @@ -1,5 +1,7 @@ package com.uid2 +import com.uid2.UID2Manager.GenerateIdentityResult +import com.uid2.data.IdentityRequest import com.uid2.data.IdentityStatus import com.uid2.data.IdentityStatus.ESTABLISHED import com.uid2.data.IdentityStatus.EXPIRED @@ -8,8 +10,9 @@ import com.uid2.data.IdentityStatus.OPT_OUT import com.uid2.data.IdentityStatus.REFRESHED import com.uid2.data.IdentityStatus.REFRESH_EXPIRED import com.uid2.data.UID2Identity -import com.uid2.network.RefreshPackage +import com.uid2.network.ResponsePackage import com.uid2.storage.StorageManager +import com.uid2.utils.InputUtils import com.uid2.utils.Logger import com.uid2.utils.TimeUtils import kotlinx.coroutines.CoroutineDispatcher @@ -30,6 +33,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock @@ -51,6 +55,7 @@ class UID2ManagerTest { private val client: UID2Client = mock() private val storageManager: StorageManager = mock() private val timeUtils: TimeUtils = mock() + private val inputUtils: InputUtils = mock() private val logger: Logger = mock() private lateinit var manager: UID2Manager @@ -63,8 +68,16 @@ class UID2ManagerTest { // By default, we won't expire tokens. whenever(timeUtils.hasExpired(anyLong())).thenReturn(false) + whenever(inputUtils.normalize(any(IdentityRequest.Email::class.java))).thenAnswer { + it.arguments[0] as IdentityRequest.Email + } + + whenever(inputUtils.normalize(any(IdentityRequest.Phone::class.java))).thenAnswer { + it.arguments[0] as IdentityRequest.Phone + } + whenever(storageManager.loadIdentity()).thenReturn(Pair(initialIdentity, initialStatus)) - manager = withManager(client, storageManager, timeUtils, testDispatcher, false, listener) + manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, listener) } @Test @@ -72,7 +85,7 @@ class UID2ManagerTest { var isInitialized = false val onInitialized = { isInitialized = true } - val manager = UID2Manager(client, storageManager, timeUtils, testDispatcher, false, logger).apply { + val manager = UID2Manager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, logger).apply { this.checkExpiration = false this.onInitialized = onInitialized } @@ -127,6 +140,60 @@ class UID2ManagerTest { assertManagerState(manager, identity, ESTABLISHED) } + @Test + fun `generates identity for different requests`() = runTest(testDispatcher) { + val subscriptionId = "sub" + val publicKey = "pub" + + listOf( + IdentityRequest.Email("test@test.com"), + IdentityRequest.EmailHash("a-hash"), + IdentityRequest.Phone("+00000000000"), + IdentityRequest.PhoneHash("another-hash"), + ).forEach { request -> + val generated = withRandomIdentity() + whenever(client.generateIdentity(request, subscriptionId, publicKey)).thenReturn( + ResponsePackage(generated, ESTABLISHED, ""), + ) + + // Request a new identity should be generated. + var result: GenerateIdentityResult? = null + manager.generateIdentity(request, subscriptionId, publicKey) { result = it } + testDispatcher.scheduler.advanceUntilIdle() + + // Verify that the identity is updated from the one provided via the Client. + assertEquals(manager.currentIdentity, generated) + + // Verify that the result callback was invoked. + assertEquals(GenerateIdentityResult.Success, result) + } + } + + @Test + fun `existing identity untouched if generation fails`() = runTest(testDispatcher) { + val subscriptionId = "sub" + val publicKey = "pub" + + val request = IdentityRequest.Email("test@test.com") + whenever(client.generateIdentity(request, subscriptionId, publicKey)).thenThrow( + PayloadDecryptException::class.java, + ) + + // Verify that the manager has a known (existing) identity. + assertEquals(manager.currentIdentity, initialIdentity) + + // Request a new identity is generated, knowing that this will fail. + var result: GenerateIdentityResult? = null + manager.generateIdentity(request, subscriptionId, publicKey) { result = it } + testDispatcher.scheduler.advanceUntilIdle() + + // Verify that after the failure, the existing identity is still present. + assertEquals(manager.currentIdentity, initialIdentity) + + // Verify that the result callback was invoked. + assertTrue(result is GenerateIdentityResult.Error) + } + @Test fun `resets identity`() = runTest(testDispatcher) { // Verify that the initial state of the manager reflects the restored Identity. @@ -145,7 +212,7 @@ class UID2ManagerTest { @Test fun `resets identity immediately after initialisation`() = runTest(testDispatcher) { // Create a new instance of the manager but *don't* allow it to finish initialising (loading previous identity) - val manager = UID2Manager(client, storageManager, timeUtils, testDispatcher, false, logger).apply { + val manager = UID2Manager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, logger).apply { onIdentityChangedListener = listener checkExpiration = false } @@ -180,7 +247,7 @@ class UID2ManagerTest { fun `reports when no identity available after opt-out`() = runTest(testDispatcher) { // Configure the client so that when asked to refresh, it actually reports that the user has now opted out. whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenReturn( - RefreshPackage( + ResponsePackage( null, OPT_OUT, "User opt-ed out", @@ -202,7 +269,7 @@ class UID2ManagerTest { val storageManager: StorageManager = mock() whenever(storageManager.loadIdentity()).thenReturn(Pair(null, NO_IDENTITY)) - val manager = withManager(client, storageManager, timeUtils, testDispatcher, false, null) + val manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, null) assertNull(manager.currentIdentity) // Verify that if we attempt to refresh the Identity when one is not set, nothing happens. @@ -215,7 +282,7 @@ class UID2ManagerTest { // Configure the client so that when asked to refresh, it returns a new Identity. val newIdentity = withRandomIdentity() whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenReturn( - RefreshPackage( + ResponsePackage( newIdentity, REFRESHED, "Refreshed", @@ -237,7 +304,7 @@ class UID2ManagerTest { fun `refresh identities opt out`() = runTest(testDispatcher) { // Configure the client so that when asked to refresh, it actually reports that the user has now opted out. whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenReturn( - RefreshPackage( + ResponsePackage( null, OPT_OUT, "User opt-ed out", @@ -263,7 +330,7 @@ class UID2ManagerTest { val newIdentity = withRandomIdentity() whenever(client.refreshIdentity(initialIdentity.refreshToken, initialIdentity.refreshResponseKey)).thenAnswer { if (hasErrored) { - return@thenAnswer RefreshPackage( + return@thenAnswer ResponsePackage( newIdentity, REFRESHED, "Refreshed", @@ -361,7 +428,7 @@ class UID2ManagerTest { whenever(timeUtils.diffToNow(anyLong())).thenReturn(TimeUnit.SECONDS.toMillis(5)) whenever(client.refreshIdentity(anyString(), anyString())).thenAnswer { refreshed = true - RefreshPackage( + ResponsePackage( newIdentity, REFRESHED, "User refreshed", @@ -369,7 +436,7 @@ class UID2ManagerTest { } // Build the Manager. - val manager = withManager(client, storageManager, timeUtils, testDispatcher, true, listener) + val manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, true, listener) testScheduler.advanceTimeBy(10) assertNull(manager.currentIdentity) @@ -409,8 +476,8 @@ class UID2ManagerTest { return@thenAnswer expiryMs <= testDispatcher.scheduler.currentTime } - // Build the Manager, allowing it to attempt to load from stroage. - val manager = withManager(client, storageManager, timeUtils, testDispatcher, false, listener, true) + // Build the Manager, allowing it to attempt to load from storage. + val manager = withManager(client, storageManager, timeUtils, inputUtils, testDispatcher, false, listener, true) testScheduler.advanceTimeBy(10) assertNull(manager.currentIdentity) @@ -460,6 +527,7 @@ class UID2ManagerTest { client: UID2Client, storageManager: StorageManager, timeUtils: TimeUtils, + inputUtils: InputUtils, dispatcher: CoroutineDispatcher, initialAutomaticRefreshEnabled: Boolean, listener: UID2ManagerIdentityChangedListener?, @@ -469,6 +537,7 @@ class UID2ManagerTest { client, storageManager, timeUtils, + inputUtils, dispatcher, initialAutomaticRefreshEnabled, logger, @@ -506,4 +575,6 @@ class UID2ManagerTest { private fun randomString(length: Int) = List(length) { charPool.random() }.joinToString("") private fun randomLong(min: Long) = Random.nextLong(min, Long.MAX_VALUE) + + private fun any(type: Class): T = Mockito.any(type) } diff --git a/sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt b/sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt new file mode 100644 index 0000000..4d8cf06 --- /dev/null +++ b/sdk/src/test/java/com/uid2/data/IdentityRequestTest.kt @@ -0,0 +1,66 @@ +package com.uid2.data + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdentityRequestTest { + + @Test + fun `test email payload`() { + val payload = IdentityRequest.Email("test.test@test.com").toPayload() + val jsonPayload = JSONObject(payload) + + // Verify that we receive the expected JSON payload + assertNotNull(jsonPayload) + assertTrue(jsonPayload.has(FIELD_EMAIL_HASH)) + assertEquals("dvECjPKZHya0/SIhSGwP0m8SgTv1vzLxPULUOsm880M=", jsonPayload[FIELD_EMAIL_HASH]) + } + + @Test + fun `test email hash payload`() { + val hash = "this-is-a-hash" + val payload = IdentityRequest.EmailHash(hash).toPayload() + val jsonPayload = JSONObject(payload) + + // Verify that we receive the expected JSON payload + assertNotNull(jsonPayload) + assertTrue(jsonPayload.has(FIELD_EMAIL_HASH)) + assertEquals(hash, jsonPayload[FIELD_EMAIL_HASH]) + assertTrue(jsonPayload.has(FIELD_OPT_OUT_CHECK)) + assertEquals(1, jsonPayload[FIELD_OPT_OUT_CHECK]) + } + + @Test + fun `test phone payload`() { + val payload = IdentityRequest.Phone("+1234567890").toPayload() + val jsonPayload = JSONObject(payload) + + // Verify that we receive the expected JSON payload + assertNotNull(jsonPayload) + assertTrue(jsonPayload.has(FIELD_PHONE_HASH)) + assertEquals("QizoLG/BckrIeAQvfQVWU6temD0YbmFoJqctQ4S2ivg=", jsonPayload[FIELD_PHONE_HASH]) + } + + @Test + fun `test phone hash payload`() { + val hash = "this-is-a-hash" + val payload = IdentityRequest.PhoneHash(hash).toPayload() + val jsonPayload = JSONObject(payload) + + // Verify that we receive the expected JSON payload + assertNotNull(jsonPayload) + assertTrue(jsonPayload.has(FIELD_PHONE_HASH)) + assertEquals(hash, jsonPayload[FIELD_PHONE_HASH]) + assertTrue(jsonPayload.has(FIELD_OPT_OUT_CHECK)) + assertEquals(1, jsonPayload[FIELD_OPT_OUT_CHECK]) + } + + private companion object { + const val FIELD_EMAIL_HASH = "email_hash" + const val FIELD_PHONE_HASH = "phone_hash" + const val FIELD_OPT_OUT_CHECK = "optout_check" + } +} diff --git a/sdk/src/test/java/com/uid2/extensions/StringExTest.kt b/sdk/src/test/java/com/uid2/extensions/StringExTest.kt index abac6ac..c718e1f 100644 --- a/sdk/src/test/java/com/uid2/extensions/StringExTest.kt +++ b/sdk/src/test/java/com/uid2/extensions/StringExTest.kt @@ -46,4 +46,23 @@ class StringExTest { assertNotNull(inputDecoded) assertEquals(input, inputDecoded?.toString(Charsets.UTF_8)) } + + @Test + fun `test SHA-256 hashing`() { + mapOf( + "test.test@test.com" to "dvECjPKZHya0/SIhSGwP0m8SgTv1vzLxPULUOsm880M=", + "testtest@gmail.com" to "LkLfFrut8Tc3h/fIvYDiBKSbaMiau/DtaLBPQYszdMw=", + "test+test@test.com" to "rQ4yzdOz4uG8N54326QyZD6/JwqrXn4lmy34cVCojB8=", + "+test@test.com" to "weFizOVVWKlLfyorbBU8oxYDv4HJtTZCPMyZ4THzUQE=", + "test@gmail.com" to "h5JGBrQTGorO7q6IaFMfu5cSqqB6XTp1aybOD11spnQ=", + "testtest@test.com" to "d1Lr/s4GLLX3SvQVMoQdIMfbQPMAGZYry+2V+0pZlQg=", + "testtest@gmail.com" to "LkLfFrut8Tc3h/fIvYDiBKSbaMiau/DtaLBPQYszdMw=", + "\uD83D\uDE0Atesttest@test.com" to "fAFEUqApQ0V/M9mLj/IO54CgKgtQuARKsOMqtFklD4k=", + "testtest@\uD83D\uDE0Atest.com" to "tcng5pttf7Y2z4ylZTROvIMw1+IVrMpR4D1KeXSrdiM=", + "testtest@test.com\uD83D\uDE0A" to "0qI21FPLkuez/8RswfmircHPYz9Dtf7/Nch1rSWEQf0=", + ).forEach { + // Hash the given string (with SHA-256) and check that the Base64 encoded output matches what we expect. + assertEquals(it.key.toSha256(), it.value) + } + } } diff --git a/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt b/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt index 1fcf1b3..588f74b 100644 --- a/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt +++ b/sdk/src/test/java/com/uid2/network/DataEnvelopeTest.kt @@ -8,12 +8,14 @@ import org.junit.Assert.assertNull import org.junit.Test class DataEnvelopeTest { + private val dataEnvelope = DataEnvelope + @Test fun `test encrypted opt out`() { - val payload = DataEnvelope.decrypt( + val payload = dataEnvelope.decrypt( TestData.REFRESH_TOKEN_ENCRYPTED_OPT_OUT_KEY, TestData.REFRESH_TOKEN_OPT_OUT_ENCRYPTED, - true, + false, ) // Verify that the payload was actually decoded. @@ -27,10 +29,10 @@ class DataEnvelopeTest { @Test fun `test encrypted refresh`() { - val payload = DataEnvelope.decrypt( + val payload = dataEnvelope.decrypt( TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY, TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED, - true, + false, ) // Verify that the payload was actually decoded. @@ -44,10 +46,10 @@ class DataEnvelopeTest { @Test fun `test invalid key`() { - val payload = DataEnvelope.decrypt( + val payload = dataEnvelope.decrypt( "This is not a key", TestData.REFRESH_TOKEN_SUCCESS_ENCRYPTED, - true, + false, ) // Verify that when attempting to decrypt valid data with an incorrect key, we are returned the expected null. @@ -56,10 +58,10 @@ class DataEnvelopeTest { @Test fun `test invalid data`() { - val payload = DataEnvelope.decrypt( + val payload = dataEnvelope.decrypt( TestData.REFRESH_TOKEN_ENCRYPTED_SUCCESS_KEY, "This is not valid", - true, + false, ) // Verify that when attempting to decrypt invalid data with a valid key, we are returned the expected null. diff --git a/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt b/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt index f5f48c4..6eb36bb 100644 --- a/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt +++ b/sdk/src/test/java/com/uid2/network/RefreshResponseTest.kt @@ -63,7 +63,7 @@ class RefreshResponseTest { assertEquals(identity, refresh?.body) // Verify that when converted to a RefreshPackage, the identity still matches what we expect. - val refreshPackage = refresh?.toRefreshPackage() + val refreshPackage = refresh?.toResponsePackage(true) assertNotNull(refreshPackage) assertEquals(identity, refreshPackage?.identity) assertEquals(REFRESHED, refreshPackage?.status) @@ -75,7 +75,7 @@ class RefreshResponseTest { JSONObject(TestData.VALID_REFRESH_OPT_OUT) to OPT_OUT, JSONObject(TestData.VALID_REFRESH_EXPIRED_TOKEN) to REFRESH_EXPIRED, ).forEach { - val refresh = RefreshResponse.fromJson(it.key)?.toRefreshPackage() + val refresh = RefreshResponse.fromJson(it.key)?.toResponsePackage(true) // Verify that the converted RefreshPackage includes the expected Status. assertNotNull(refresh) diff --git a/sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt b/sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt new file mode 100644 index 0000000..0c48233 --- /dev/null +++ b/sdk/src/test/java/com/uid2/utils/InputUtilsTest.kt @@ -0,0 +1,129 @@ +package com.uid2.utils + +import com.uid2.InputValidationException +import com.uid2.data.IdentityRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class InputUtilsTest { + + @Test + fun `test phone number normalization detects invalid input`() { + val utils = InputUtils() + + listOf( + "", + "asdaksjdakfj", + "DH5qQFhi5ALrdqcPiib8cy0Hwykx6frpqxWCkR0uijs", + "QFhi5ALrdqcPiib8cy0Hwykx6frpqxWCkR0uijs", + "06a418f467a14e1631a317b107548a1039d26f12ea45301ab14e7684b36ede58", + "0C7E6A405862E402EB76A70F8A26FC732D07C32931E9FAE9AB1582911D2E8A3B", + "+", + "12345678", + "123456789", + "1234567890", + "+12345678", + "+123456789", + "+ 12345678", + "+ 123456789", + "+ 1234 5678", + "+ 1234 56789", + "+1234567890123456", + "+1234567890A", + "+1234567890 ", + "+1234567890+", + "+12345+67890", + "555-555-5555", + "(555) 555-5555", + ).forEach { + // Verify that when attempting to normalize these invalid phone numbers, the expected exception is thrown. + assertThrows(InputValidationException::class.java) { + utils.normalize(IdentityRequest.Phone(it)) + } + } + } + + @Test + fun `test phone number normalization detects valid input`() { + val utils = InputUtils() + + listOf( + "+1234567890", + "+12345678901", + "+123456789012", + "+1234567890123", + "+12345678901234", + "+123456789012345", + ).forEach { + // Verify that when normalizing these valid phone numbers, they pass as expected (untouched). + val normalized = utils.normalize(IdentityRequest.Phone(it)) + assertEquals(it, normalized.data) + } + } + + @Test + fun `test email normalization detects invalid input`() { + val utils = InputUtils() + + listOf( + "", + " @", + "@", + "a@", + "@b", + "@b.com", + "+", + " ", + "+@gmail.com", + ".+@gmail.com", + "a@ba@z.com", + ).forEach { + // Verify that when attempting to normalize these invalid email addresses, the expected exception is thrown. + assertThrows(InputValidationException::class.java) { + utils.normalize(IdentityRequest.Email(it)) + } + } + } + + @Test + fun `test email normalization detects valid input`() { + val utils = InputUtils() + + mapOf( + "TEst.TEST@Test.com " to "test.test@test.com", + "test.test@test.com" to "test.test@test.com", + "test.test@gmail.com" to "testtest@gmail.com", + "test+test@test.com" to "test+test@test.com", + "+test@test.com" to "+test@test.com", + "test+test@gmail.com" to "test@gmail.com", + "testtest@test.com" to "testtest@test.com", + " testtest@test.com" to "testtest@test.com", + "testtest@test.com " to "testtest@test.com", + " testtest@test.com " to "testtest@test.com", + " testtest@test.com " to "testtest@test.com", + " test.test@gmail.com" to "testtest@gmail.com", + "test.test@gmail.com " to "testtest@gmail.com", + " test.test@gmail.com " to "testtest@gmail.com", + " test.test@gmail.com " to "testtest@gmail.com", + "TEstTEst@gmail.com " to "testtest@gmail.com", + "TEstTEst@GMail.Com " to "testtest@gmail.com", + " TEstTEst@GMail.Com " to "testtest@gmail.com", + "TEstTEst@GMail.Com" to "testtest@gmail.com", + "TEst.TEst@GMail.Com" to "testtest@gmail.com", + "TEst.TEst+123@GMail.Com" to "testtest@gmail.com", + "TEst.TEST@Test.com " to "test.test@test.com", + "TEst.TEST@Test.com " to "test.test@test.com", + "\uD83D\uDE0Atesttest@test.com" to "\uD83D\uDE0Atesttest@test.com", + "testtest@\uD83D\uDE0Atest.com" to "testtest@\uD83D\uDE0Atest.com", + "testtest@test.com\uD83D\uDE0A" to "testtest@test.com\uD83D\uDE0A", + ).forEach { + // Verify that when normalizing these valid phone numbers, they pass as expected. + val normalized = utils.normalize(IdentityRequest.Email(it.key)) + assertEquals(it.value, normalized.data) + } + } +}