diff --git a/android-sdk/src/main/java/com/checkout/android_sdk/CheckoutAPIClient.java b/android-sdk/src/main/java/com/checkout/android_sdk/CheckoutAPIClient.java index 767ed25b9..a5ef2c692 100755 --- a/android-sdk/src/main/java/com/checkout/android_sdk/CheckoutAPIClient.java +++ b/android-sdk/src/main/java/com/checkout/android_sdk/CheckoutAPIClient.java @@ -1,6 +1,8 @@ package com.checkout.android_sdk; import android.content.Context; +import android.text.TextUtils; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -12,7 +14,10 @@ import com.checkout.android_sdk.Response.CardTokenisationResponse; import com.checkout.android_sdk.Response.GooglePayTokenisationFail; import com.checkout.android_sdk.Response.GooglePayTokenisationResponse; +import com.checkout.android_sdk.Response.JWK; +import com.checkout.android_sdk.Response.JWKSResponse; import com.checkout.android_sdk.Utils.Environment; +import com.checkout.android_sdk.Utils.JWEEncrypt; import com.checkout.android_sdk.network.NetworkError; import com.checkout.android_sdk.network.utils.OkHttpTokenRequestor; import com.checkout.android_sdk.network.utils.TokenRequestor; @@ -21,6 +26,7 @@ import org.json.JSONException; import org.json.JSONObject; +import java.util.List; import java.util.UUID; public class CheckoutAPIClient { @@ -47,16 +53,26 @@ public interface OnGooglePayTokenGenerated { void onNetworkError(NetworkError error); } - @NonNull private final Context mContext; - @NonNull private final Environment mEnvironment; - @NonNull private final String mKey; - @NonNull private final FramesLogger mLogger; + public interface OnJWKSFetched { + void onJWKSFetched(JWKSResponse response); + + void onError(Throwable throwable); + } + + @NonNull + private final Context mContext; + @NonNull + private final Environment mEnvironment; + @NonNull + private final String mKey; + @NonNull + private final FramesLogger mLogger; private CheckoutAPIClient.OnTokenGenerated mTokenListener; private CheckoutAPIClient.OnGooglePayTokenGenerated mGooglePayTokenListener; /** - * @deprecated explicitly define the environment to avoid using default environment value. * @see #CheckoutAPIClient(Context, String, Environment) + * @deprecated explicitly define the environment to avoid using default environment value. */ @Deprecated public CheckoutAPIClient(@NonNull Context context, @NonNull String key) { @@ -101,11 +117,47 @@ public void generateToken(CardTokenisationRequest request) { Gson gson = new Gson(); TokenRequestor requester = new OkHttpTokenRequestor(mEnvironment, mKey, gson, mLogger); - requester.requestCardToken( - correlationID.toString(), - gson.toJson(request), - mTokenListener - ); + requester.fetchJWKS(new OnJWKSFetched() { + @Override + public void onJWKSFetched(JWKSResponse response) { + List jwks = response.getKeys(); + if (jwks.isEmpty()) { + mLogger.errorEvent( + "No JWKS found. Key set is empty." + ); + mTokenListener.onError(new CardTokenisationFail()); + } else { + JWK jwk = jwks.get(0); + String token = generateTokenUsingJOSE(gson, jwk, request); + if (!TextUtils.isEmpty(token)) { + mLogger.debugEvent("Processing card tokenisation request using JWE " + jwk); + requester.requestCardToken( + correlationID.toString(), + token, + true, + mTokenListener + ); + } else { + mLogger.debugEvent("Processing plain text card tokenisation request"); + requester.requestCardToken( + correlationID.toString(), + gson.toJson(request), + false, + mTokenListener + ); + } + } + } + + @Override + public void onError(Throwable throwable) { + mLogger.errorEvent( + "Error occurred while fetching JWKS", + throwable + ); + mTokenListener.onError(new CardTokenisationFail()); + } + }); } catch (Exception e) { mLogger.errorEvent( "Error occurred in Card tokenisation request", @@ -152,6 +204,23 @@ public void generateGooglePayToken(String payload) throws JSONException { } } + private String generateTokenUsingJOSE(Gson gson, JWK jwk, CardTokenisationRequest request) { + String token = ""; + try { + byte[] payloadBytes = gson.toJson(request).getBytes(); + token = JWEEncrypt.encrypt( + jwk.getN(), + jwk.getE(), + jwk.getKid(), + payloadBytes + ); + return token; + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + return ""; + } + /** * This method used to set a callback for 3D Secure handling. * diff --git a/android-sdk/src/main/java/com/checkout/android_sdk/Response/JWKSResponse.kt b/android-sdk/src/main/java/com/checkout/android_sdk/Response/JWKSResponse.kt new file mode 100644 index 000000000..83028eb82 --- /dev/null +++ b/android-sdk/src/main/java/com/checkout/android_sdk/Response/JWKSResponse.kt @@ -0,0 +1,30 @@ +package com.checkout.android_sdk.Response + +import com.google.gson.annotations.SerializedName + +data class JWKSResponse( + + @field:SerializedName("keys") + val keys: List +) + +data class JWK( + + @field:SerializedName("kty") + val kty: String, + + @field:SerializedName("e") + val E: String, + + @field:SerializedName("use") + val use: String, + + @field:SerializedName("kid") + val kid: String, + + @field:SerializedName("alg") + val alg: String, + + @field:SerializedName("n") + val N: String +) diff --git a/android-sdk/src/main/java/com/checkout/android_sdk/Utils/Environment.java b/android-sdk/src/main/java/com/checkout/android_sdk/Utils/Environment.java index 1af72d6ae..3001af556 100755 --- a/android-sdk/src/main/java/com/checkout/android_sdk/Utils/Environment.java +++ b/android-sdk/src/main/java/com/checkout/android_sdk/Utils/Environment.java @@ -1,14 +1,24 @@ package com.checkout.android_sdk.Utils; public enum Environment { - SANDBOX("https://api.sandbox.checkout.com/tokens", "https://api.sandbox.checkout.com/tokens"), - LIVE("https://api.checkout.com/tokens", "https://api.checkout.com/tokens"); + SANDBOX( + "https://api.sandbox.checkout.com/tokens", + "https://api.sandbox.checkout.com/.well-known/content-encoding/jwks", + "https://api.sandbox.checkout.com/tokens" + ), + LIVE( + "https://api.checkout.com/tokens", + "https://api.checkout.com/.well-known/content-encoding/jwks", + "https://api.checkout.com/tokens" + ); public final String token; + public final String jwks; public final String googlePay; - Environment(String token, String googlePay) { + Environment(String token, String jwks, String googlePay) { this.token = token; + this.jwks = jwks; this.googlePay = googlePay; } } \ No newline at end of file diff --git a/android-sdk/src/main/java/com/checkout/android_sdk/Utils/JWEEncrypt.kt b/android-sdk/src/main/java/com/checkout/android_sdk/Utils/JWEEncrypt.kt new file mode 100644 index 000000000..6b072dd9a --- /dev/null +++ b/android-sdk/src/main/java/com/checkout/android_sdk/Utils/JWEEncrypt.kt @@ -0,0 +1,123 @@ +package com.checkout.android_sdk.Utils + +import android.util.Base64 +import org.json.JSONObject +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.SecureRandom +import java.security.interfaces.RSAPublicKey +import java.security.spec.MGF1ParameterSpec +import java.security.spec.RSAPublicKeySpec +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.crypto.spec.SecretKeySpec + + +fun ByteArray.encodeUrlSafeBase64(): String = + Base64.encodeToString(this, Base64.NO_PADDING or Base64.URL_SAFE or Base64.NO_WRAP) + +fun String.decodeUrlSafeBase64(): ByteArray = + Base64.decode(toByteArray(), Base64.NO_PADDING or Base64.URL_SAFE or Base64.NO_WRAP) + +fun jsonObject(vararg pairs: Pair) = JSONObject().apply { + pairs.forEach { put(it.first, it.second) } +} + +object JWEEncrypt { + + private const val IV_BIT_LENGTH = 96 + private const val AUTH_TAG_BIT_LENGTH = 128 + private const val AES_GCM_KEY_BIT_LENGTH = 256 + + @Throws(Throwable::class) + @JvmStatic + fun encrypt( + rsaPublicKeyModulus: String, + rsaPublicKeyExponent: String, + rsaKeyId: String, + payload: ByteArray + ): String { + + val modulus = BigInteger(1, rsaPublicKeyModulus.decodeUrlSafeBase64()) + val exponent = BigInteger(1, rsaPublicKeyExponent.decodeUrlSafeBase64()) + + val spec = RSAPublicKeySpec(modulus, exponent) + + val rsaKeyFactory = KeyFactory.getInstance("RSA") + val rsaPublicKey = rsaKeyFactory.generatePublic(spec) as RSAPublicKey + + val headerBytes = jsonObject( + "kid" to rsaKeyId, + "typ" to "JOSE", + "enc" to "A256GCM", + "alg" to "RSA-OAEP-256" + ).toString().toByteArray() + val encoderHeader = headerBytes.encodeUrlSafeBase64() + + val cekKey = SecretKeySpec( + generateSecureBytes(AES_GCM_KEY_BIT_LENGTH), + "AES" + ) + + val gcmSpec = GCMParameterSpec( + AUTH_TAG_BIT_LENGTH, + generateSecureBytes(IV_BIT_LENGTH) + ) + + val cipherOutput = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.ENCRYPT_MODE, cekKey, gcmSpec) + updateAAD(encoderHeader.toByteArray()) + doFinal(payload) + } + + val tagPos: Int = cipherOutput.size - (AUTH_TAG_BIT_LENGTH / 8) + val cipherTextBytes = subArray(cipherOutput, 0, tagPos) + val authTagBytes = subArray(cipherOutput, tagPos, (AUTH_TAG_BIT_LENGTH / 8)) + + // Encrypt the Content Encryption Key (CEK) with public RSA certificate + val encryptedCekKey = encryptContentEncryptionKey( + rsaPublicKey = rsaPublicKey, + cek = cekKey.encoded + ) + + val encodedEncryptedKey = encryptedCekKey.encodeUrlSafeBase64() + val encodedIv = gcmSpec.iv.encodeUrlSafeBase64() + val encodedCipherText = cipherTextBytes.encodeUrlSafeBase64() + val encodedAuth = authTagBytes.encodeUrlSafeBase64() + + return "$encoderHeader.$encodedEncryptedKey.$encodedIv.$encodedCipherText.$encodedAuth" + } + + private fun encryptContentEncryptionKey( + rsaPublicKey: RSAPublicKey, + cek: ByteArray + ): ByteArray { + val algParam = AlgorithmParameters.getInstance("OAEP") + algParam.init( + OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ) + ) + // Encrypt the Content Encryption Key (CEK) with public RSA certificate + return Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding").run { + init(Cipher.ENCRYPT_MODE, rsaPublicKey, algParam) + doFinal(cek) + } + } + + private fun generateSecureBytes(sizeInBits: Int) = + ByteArray(sizeInBits / 8).apply { + SecureRandom().nextBytes(this) + } + + private fun subArray(byteArray: ByteArray, beginIndex: Int, length: Int) = + ByteArray(length).apply { + System.arraycopy(byteArray, beginIndex, this, 0, size) + } +} \ No newline at end of file diff --git a/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/OkHttpTokenRequestor.kt b/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/OkHttpTokenRequestor.kt index f81427ef1..dc9e3bbdb 100644 --- a/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/OkHttpTokenRequestor.kt +++ b/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/OkHttpTokenRequestor.kt @@ -6,16 +6,18 @@ import android.util.Log import com.checkout.android_sdk.BuildConfig import com.checkout.android_sdk.CheckoutAPIClient import com.checkout.android_sdk.FramesLogger +import com.checkout.android_sdk.Response.JWKSResponse import com.checkout.android_sdk.Response.TokenisationResponse import com.checkout.android_sdk.Utils.Environment import com.checkout.android_sdk.network.InternalCardTokenGeneratedListener import com.checkout.android_sdk.network.InternalGooglePayTokenGeneratedListener import com.google.gson.Gson +import com.google.gson.JsonParseException +import okhttp3.* import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.logging.HttpLoggingInterceptor +import java.io.IOException import java.util.concurrent.TimeUnit internal class OkHttpTokenRequestor( @@ -39,6 +41,7 @@ internal class OkHttpTokenRequestor( override fun requestCardToken( correlationID: String?, requestBody: String, + joseRequest: Boolean, listener: CheckoutAPIClient.OnTokenGenerated ) { val internalListener = InternalCardTokenGeneratedListener( @@ -55,6 +58,7 @@ internal class OkHttpTokenRequestor( environment.token, key, requestBody, + joseRequest, callback ) } @@ -78,6 +82,7 @@ internal class OkHttpTokenRequestor( environment.googlePay, key, requestBody, + false, callback ) } @@ -86,6 +91,7 @@ internal class OkHttpTokenRequestor( url: String, correlationID: String, requestBody: String, + joseRequest: Boolean, callback: OkHttpTokenCallback ) { @@ -94,7 +100,11 @@ internal class OkHttpTokenRequestor( .addHeader(HEADER_AUTHORIZATION, key) .addHeader(HEADER_USER_AGENT_NAME, HEADER_USER_AGENT_VALUE) .addHeaderIfNotEmpty(HEADER_CKO_CORRELATION_ID, correlationID) - .post(requestBody.toRequestBody(jsonMediaType)) + .post( + requestBody.toByteArray() + .toRequestBody(if (joseRequest) joseMediaType else jsonMediaType) + ) + .build() okHttpClient @@ -102,17 +112,55 @@ internal class OkHttpTokenRequestor( .enqueue(callback) } + override fun fetchJWKS(listener: CheckoutAPIClient.OnJWKSFetched) { + val jwksRequest = Request.Builder() + .url(environment.jwks) + .build() + okHttpClient + .newCall(jwksRequest) + .enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + responseHandler().post { + listener.onError(e) + } + } + + override fun onResponse(call: Call, response: Response) { + responseHandler().post { + val responseBody = response.body + if (response.isSuccessful && responseBody != null) { + try { + listener.onJWKSFetched( + gson.fromJson( + responseBody.string(), + JWKSResponse::class.java + ) + ) + } catch (e: JsonParseException) { + listener.onError(e) + } + } else { + listener.onError(null) + } + } + } + }) + + } + companion object { private val LOGGING_ENABLED = BuildConfig.DEBUG private const val HEADER_AUTHORIZATION = "Authorization" private const val HEADER_USER_AGENT_NAME = "User-Agent" - private const val HEADER_USER_AGENT_VALUE = "checkout-sdk-frames-android/${BuildConfig.PRODUCT_VERSION}" + private const val HEADER_USER_AGENT_VALUE = + "checkout-sdk-frames-android/${BuildConfig.PRODUCT_VERSION}" private const val HEADER_CKO_CORRELATION_ID = "Cko-Correlation-Id" private const val CALL_TIMEOUT_MS = 10000L private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + private val joseMediaType = "application/jose".toMediaType() private fun newOkHttpClient(): OkHttpClient = OkHttpClient.Builder() .retryOnConnectionFailure(true) diff --git a/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/TokenRequestor.kt b/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/TokenRequestor.kt index c7f011a00..c99fb8a89 100644 --- a/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/TokenRequestor.kt +++ b/android-sdk/src/main/java/com/checkout/android_sdk/network/utils/TokenRequestor.kt @@ -1,5 +1,6 @@ package com.checkout.android_sdk.network.utils +import com.checkout.android_sdk.CheckoutAPIClient import com.checkout.android_sdk.CheckoutAPIClient.OnGooglePayTokenGenerated import com.checkout.android_sdk.CheckoutAPIClient.OnTokenGenerated @@ -8,6 +9,7 @@ internal interface TokenRequestor { fun requestCardToken( correlationID: String?, requestBody: String, + joseRequest: Boolean, listener: OnTokenGenerated ) @@ -16,4 +18,8 @@ internal interface TokenRequestor { requestBody: String, listener: OnGooglePayTokenGenerated ) + + fun fetchJWKS( + listener: CheckoutAPIClient.OnJWKSFetched + ) } \ No newline at end of file