From 33527a8107ecda163f1bdaf55fb04b358b8f2838 Mon Sep 17 00:00:00 2001 From: Jan Kobersky <5406945+kober32@users.noreply.github.com> Date: Thu, 30 Nov 2023 08:06:00 +0100 Subject: [PATCH] PACUtils (#117) --- build.gradle.kts | 2 + docs/Using-Operations-Service.md | 26 ++- library/build.gradle.kts | 1 + library/gradle.properties | 2 +- .../operation/{TOTPUtils.kt => PACUtils.kt} | 59 ++++--- library/src/test/java/PACUtilsTests.kt | 154 ++++++++++++++++++ 6 files changed, 216 insertions(+), 28 deletions(-) rename library/src/main/java/com/wultra/android/mtokensdk/operation/{TOTPUtils.kt => PACUtils.kt} (57%) create mode 100644 library/src/test/java/PACUtilsTests.kt diff --git a/build.gradle.kts b/build.gradle.kts index 2a923f7..4c87821 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,8 @@ buildscript { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Constants.BuildScript.kotlinVersion}") // releasing classpath("io.github.gradle-nexus:publish-plugin:1.1.0") + // tests + classpath("com.github.bjoernq:unmockplugin:0.7.9") } } diff --git a/docs/Using-Operations-Service.md b/docs/Using-Operations-Service.md index 4ddb1e8..7d186ae 100644 --- a/docs/Using-Operations-Service.md +++ b/docs/Using-Operations-Service.md @@ -11,7 +11,8 @@ - [Operations API Reference](#operations-api-reference) - [UserOperation](#useroperation) - [Creating a Custom Operation](#creating-a-custom-operation) -- [TOTP ProximityCheck](#totp-proximitycheck) +- [ProximityCheck](#proximitycheck) + ## Introduction @@ -559,3 +560,26 @@ When the app is launched via a deeplink, preserve the data from the deeplink and - Authorizing the ProximityCheck When authorizing, the SDK will by default add `timestampSigned` to the `ProximityCheck` object. This timestamp indicates when the operation was signed. + +### PACUtils +- For convenience, utility class for parsing and extracting data from QR codes and deeplinks used in the PAC (Proximity Anti-fraud Check), is provided. + +```kotlin +/** Data payload which is returned from the parser */ +data class PACData( + + /** The ID of the operation associated with the TOTP */ + val operationId: String, + + /** The actual Time-based one time password */ + val totp: String? +) +``` + +- two methods are provided: + - `parseDeeplink(uri: Uri): PACData?` - uri is expected to be in format `"scheme://code=$JWT"` or `scheme://operation?oid=5b753d0d-d59a-49b7-bec4-eae258566dbb&potp=12345678}` + - `parseQRCode(code: String): PACData?` - code is to be expected in the same format as deeplink formats or as a plain JWT + - mentioned JWT should be in format `{“typ”:”JWT”, “alg”:”none”}.{“oid”:”5b753d0d-d59a-49b7-bec4-eae258566dbb”, “potp”:”12345678”} ` + +- Accepted formats: + - notice that totp key in JWT and in query shall be `potp`! \ No newline at end of file diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 88ea803..f6f865a 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -19,6 +19,7 @@ plugins { id("com.android.library") kotlin("android") + id("de.mobilej.unmock") } android { diff --git a/library/gradle.properties b/library/gradle.properties index 0f512d7..43b3aa3 100644 --- a/library/gradle.properties +++ b/library/gradle.properties @@ -14,6 +14,6 @@ # and limitations under the License. # -VERSION_NAME=1.8.0-SNAPSHOT +VERSION_NAME=1.8.1-SNAPSHOT GROUP_ID=com.wultra.android.mtokensdk ARTIFACT_ID=wultra-mtoken-sdk diff --git a/library/src/main/java/com/wultra/android/mtokensdk/operation/TOTPUtils.kt b/library/src/main/java/com/wultra/android/mtokensdk/operation/PACUtils.kt similarity index 57% rename from library/src/main/java/com/wultra/android/mtokensdk/operation/TOTPUtils.kt rename to library/src/main/java/com/wultra/android/mtokensdk/operation/PACUtils.kt index bdf852f..75db36a 100644 --- a/library/src/main/java/com/wultra/android/mtokensdk/operation/TOTPUtils.kt +++ b/library/src/main/java/com/wultra/android/mtokensdk/operation/PACUtils.kt @@ -23,48 +23,55 @@ import com.google.gson.annotations.SerializedName import com.wultra.android.mtokensdk.common.Logger /** - * Utility class used for handling TOTP + * Utility class used for handling Proximity Anti-fraud Checks */ -class TOTPUtils { +class PACUtils { - /** Data payload which is returned from JWT parser */ - data class OperationTOTPData( + /** Data payload which is returned from the parser */ + data class PACData( - /** The ID of the operations associated with the TOTP */ - @SerializedName("potp") - val totp: String, + /** The ID of the operation associated with the TOTP */ + @SerializedName("oid") + val operationId: String, /** The actual Time-based one time password */ - @SerializedName("oid") - val operationId: String + @SerializedName(value = "potp", alternate = ["totp"]) + val totp: String? ) companion object { /** Method accepts deeplink Uri and returns payload data or null */ - fun parseDeeplink(uri: Uri?): OperationTOTPData? { - val query = uri?.query ?: return null - val queryItems = query.split("&").associate { - val (key, value) = it.split("=") - key to value - } + fun parseDeeplink(uri: Uri): PACData? { - queryItems["code"]?.let { - return parseJWT(it) + // Deeplink can have two query items with operationId & optional totp or single query item with JWT value + uri.getQueryParameter("oid")?.let { operationId -> + if (uri.query?.contains(operationId) == false) { + Logger.e("Operation could not be resolved - probably contains invalid characters - please, encode the URL first") + return null + } + val totp = uri.getQueryParameter("totp") ?: uri.getQueryParameter("potp") + return PACData(operationId, totp) + } ?: uri.queryParameterNames.firstOrNull()?.let { + return parseJWT(uri.getQueryParameter(it) ?: "") } ?: run { - Logger.e("Failed to parse deeplink. Key `code` not found") + Logger.e("Failed to parse deeplink. Valid keys not found in Uri: $uri") + return null } - - Logger.e("Failed to parse deeplink from $uri") - return null } - /** Method accepts scanned code as a String and returns payload data or null */ - fun parseQRCode(code: String): OperationTOTPData? { - return parseJWT(code) + /** Method accepts scanned code as a String and returns PAC data */ + fun parseQRCode(code: String): PACData? { + val uri = Uri.parse(code) + // if the QR code is in the deeplink format parse it the same way as the deeplink + return if (uri.scheme != null) { + parseDeeplink(uri) + } else { + parseJWT(code) + } } - private fun parseJWT(code: String): OperationTOTPData? { + private fun parseJWT(code: String): PACData? { val jwtParts = code.split(".") if (jwtParts.size > 1) { // At this moment we don't care about header, we want only payload which is the second part of JWT @@ -74,7 +81,7 @@ class TOTPUtils { return try { val dataPayload = Base64.decode(base64EncodedData, Base64.DEFAULT) val json = String(dataPayload, Charsets.UTF_8) - Gson().fromJson(json, OperationTOTPData::class.java) + Gson().fromJson(json, PACData::class.java) } catch (e: Exception) { Logger.e("Failed to decode QR JWT from: $code") Logger.e("With error: ${e.message}") diff --git a/library/src/test/java/PACUtilsTests.kt b/library/src/test/java/PACUtilsTests.kt new file mode 100644 index 0000000..6ab2bdd --- /dev/null +++ b/library/src/test/java/PACUtilsTests.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2022 Wultra s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions + * and limitations under the License. + */ + +package com.wultra.android.mtokensdk.api.operation + +import android.net.Uri +import com.wultra.android.mtokensdk.operation.PACUtils +import org.junit.Assert +import org.junit.Test + +class PACUtilsTests { + + @Test + fun `test parseQRCode with empty code`() { + val code = "" + Assert.assertNull(PACUtils.parseQRCode(code)) + } + + @Test + fun testQRPACParserWithShortInvalidCode() { + val code = "abc" + Assert.assertNull(PACUtils.parseQRCode(code)) + } + + @Test + fun testQRTPACParserWithValidDeeplinkCode() { + val code = "scheme://operation?oid=6a1cb007-ff75-4f40-a21b-0b546f0f6cad&potp=73743194" + val parsed = PACUtils.parseQRCode(code) + Assert.assertEquals("Parsing of totp", "73743194", parsed?.totp) + Assert.assertEquals("Parsing of operationId", "6a1cb007-ff75-4f40-a21b-0b546f0f6cad", parsed?.operationId) + } + + @Test + fun testQRTPACParserWithInvalidDeeplinkCodeAndBase64OID() { + val code = "scheme://operation?oid=E/+DRFVmd4iZABEiM0RVZneImQARIjNEVWZ3iJkAESIzRFVmd4iZAA=&totp=12345678" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull(parsed?.totp) + Assert.assertNull(parsed?.operationId) + } + + @Test + fun testQRTPACParserWithValidDeeplinkCodeAndBase64EncodedOID() { + val code = "scheme://operation?oid=E%2F%2BDRFVmd4iZABEiM0RVZneImQARIjNEVWZ3iJkAESIzRFVmd4iZAA%3D&totp=12345678" + val parsed = PACUtils.parseQRCode(code) + Assert.assertEquals("Parsing of totp", "12345678", parsed?.totp) + Assert.assertEquals("Parsing of operationId", "E/+DRFVmd4iZABEiM0RVZneImQARIjNEVWZ3iJkAESIzRFVmd4iZAA=", parsed?.operationId) + } + + fun testQRPACParserWithValidJWT() { + val code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiIzYjllZGZkMi00ZDgyLTQ3N2MtYjRiMy0yMGZhNWM5OWM5OTMiLCJwb3RwIjoiMTQzNTc0NTgifQ==" + val parsed = PACUtils.parseQRCode(code) + Assert.assertEquals("Parsing of totp", "14357458", parsed?.totp) + Assert.assertEquals("Parsing of operationId", "3b9edfd2-4d82-477c-b4b3-20fa5c99c993", parsed?.operationId) + } + + @Test + fun testQRPACParserWithValidJWTWithoutPadding() { + val code = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJvaWQiOiJMRG5JY0NjRGhjRHdHNVNLejhLeWdQeG9PbXh3dHpJc29zMEUrSFBYUHlvIiwicG90cCI6IjU4NTkwMDU5In0" + val parsed = PACUtils.parseQRCode(code) + Assert.assertEquals("Parsing of totp", "58590059", parsed?.totp) + Assert.assertEquals("Parsing of operationId", "LDnIcCcDhcDwG5SKz8KygPxoOmxwtzIsos0E+HPXPyo", parsed?.operationId) + } + + @Test + fun testQRPACParserWithInvalidJWT() { + val code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9eyJvaWQiOiIzYjllZGZkMi00ZDgyLTQ3N2MtYjRiMy0yMGZhNWM5OWM5OTMiLCJwb3RwIjoiMTQzNTc0NTgifQ==" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull("Parsing of should fail", parsed) + } + + @Test + fun testQRPACParserWithInvalidJWT2() { + val code = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.1eyJvaWQiOiJMRG5JY0NjRGhjRHdHNVNLejhLeWdQeG9PbXh3dHpJc29zMEUrSFBYUHlvIiwicG90cCI6IjU4NTkwMDU5In0" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull("Parsing of should fail", parsed) + } + + @Test + fun testQRPACParserWithInvalidJWT3() { + val code = "" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull("Parsing of should fail", parsed) + } + + @Test + fun testQRPACParserWithInvalidJWT4() { + val code = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.1eyJvaWQiOiJMRG5JY0NjR.GhjRHdHNVNLejhLeWdQeG9PbXh3dHpJc29zMEUrSFBYUHlvIiwicG90cCI6IjU4NTkwMDU5In0=====" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull("Parsing of should fail", parsed) + } + + @Test + fun testDeeplinkParserWithInvalidPACCode() { + val code = "operation?oid=df6128fc-ca51-44b7-befa-ca0e1408aa63&potp=56725494" + Assert.assertNull(PACUtils.parseQRCode(code)) + } + + @Test + fun testDeeplinkPACParserWithInvalidURL() { + val url = Uri.parse("scheme://an-invalid-url.com") + Assert.assertNull(PACUtils.parseDeeplink(url)) + } + + @Test + fun testDeeplinkParserWithValidURLButInvalidQuery() { + val url = Uri.parse("scheme://operation?code=abc") + Assert.assertNull(PACUtils.parseDeeplink(url)) + } + + @Test + fun testDeeplinkPACParserWithValidJWTCode() { + val url = Uri.parse("scheme://operation?code=eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiIzYjllZGZkMi00ZDgyLTQ3N2MtYjRiMy0yMGZhNWM5OWM5OTMiLCJwb3RwIjoiMTQzNTc0NTgifQ==") + val parsed = PACUtils.parseDeeplink(url) + Assert.assertEquals("Parsing of totp failed", "14357458", parsed?.totp) + Assert.assertEquals("Parsing of operationId failed", "3b9edfd2-4d82-477c-b4b3-20fa5c99c993", parsed?.operationId,) + } + + @Test + fun testDeeplinkParserWithValidPACCode() { + val url = Uri.parse("scheme://operation?oid=df6128fc-ca51-44b7-befa-ca0e1408aa63&potp=56725494") + val parsed = PACUtils.parseDeeplink(url) + Assert.assertEquals("Parsing of totp failed", "56725494", parsed?.totp) + Assert.assertEquals("Parsing of operationId failed", "df6128fc-ca51-44b7-befa-ca0e1408aa63", parsed?.operationId) + } + + @Test + fun testDeeplinkPACParserWithValidAnonymousDeeplinkQRCode() { + val code = "scheme://operation?oid=df6128fc-ca51-44b7-befa-ca0e1408aa63" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull(parsed?.totp) + Assert.assertEquals("Parsing of operationId failed", "df6128fc-ca51-44b7-befa-ca0e1408aa63", parsed?.operationId) + } + + @Test + fun testDeeplinkPACParserWithAnonymousJWTQRCodeWithOnlyOperationId() { + val code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiI1YWM0YjNlOC05MjZmLTQ1ZjAtYWUyOC1kMWJjN2U2YjA0OTYifQ==" + val parsed = PACUtils.parseQRCode(code) + Assert.assertNull(parsed?.totp) + Assert.assertEquals("Parsing of operationId failed", "5ac4b3e8-926f-45f0-ae28-d1bc7e6b0496", parsed?.operationId) + } +}