Skip to content

send card tokenisation to checkout server in JOSE format #60

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -21,6 +26,7 @@
import org.json.JSONException;
import org.json.JSONObject;

import java.util.List;
import java.util.UUID;

public class CheckoutAPIClient {
Expand All @@ -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) {
Expand Down Expand Up @@ -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<JWK> 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",
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<JWK?>
)

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
)
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Any>) = 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)
}
}
Loading