Skip to content

Replace crypto implementation with stronger key generation method #158

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

Merged
merged 23 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c339e3e
dev: rename StringCipher.java to StringCipherDeprecated.java
YkSix Aug 22, 2023
512d78f
dev: add interface StringCipher
YkSix Aug 22, 2023
9bec8ff
dev: make class StringCipherDeprecated implement StringCipher interface
YkSix Aug 22, 2023
f415755
dev: change the type of EncryptorHolder#ENCRYPTOR to StringCipher int…
YkSix Aug 22, 2023
8ca87fb
dev: create empty class: StringAesCipher.kt
YkSix Aug 22, 2023
36bd34e
dev: fix wrong startIndex of keyBytes array although the values are t…
YkSix Aug 23, 2023
8a3c573
dev: implement StringAesCipher; encrypt data using a secret key store…
YkSix Aug 25, 2023
61ec712
dev: move class SecretKeys to a new file and kotlinize it
YkSix Sep 7, 2023
1771435
dev: change property type of CipherData to ByteArray
YkSix Sep 8, 2023
5536deb
dev: calculate HMAC value and store it in CipherData
YkSix Sep 11, 2023
d69d5c4
dev: verify HMAC value before proceeding with the decryption
YkSix Sep 11, 2023
6f74696
dev: rename to verifyHmacValue, throw Exception if the HMAC values do…
YkSix Sep 11, 2023
3bfe42a
dev: make StringAesCipher a thread-safe class, wrap code in synchroni…
YkSix Sep 12, 2023
ec80a29
dev: add PURPOSE_SIGN in IntegrityKey
YkSix Sep 12, 2023
6a77a22
dev: switch to new AES crypto implementation
YkSix Sep 13, 2023
fb4af3b
Revert "dev: move class SecretKeys to a new file and kotlinize it"
YkSix Sep 14, 2023
b29e888
dev: modify test
YkSix Sep 15, 2023
3089706
dev: rename functions to encodeToBase64String and decodeFromBase64String
YkSix Sep 15, 2023
dcad1d8
dev: use joinToString in encodeToBase64String
YkSix Sep 15, 2023
5701fab
dev: add more comments
YkSix Sep 18, 2023
9240c23
dev: add @deprecated annotation
YkSix Sep 18, 2023
de0863f
chore: update version to 5.10.0
YkSix Dec 5, 2023
fd7cc70
chore: update versionCode to 5_10_00
YkSix Dec 6, 2023
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
4 changes: 2 additions & 2 deletions line-sdk/build.gradle
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also help to update versionCode to 5_10_00 on line 18.

Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ apply plugin: 'maven-publish'
apply plugin: 'signing'

group = "com.linecorp.linesdk"
version = "5.9.1"
version = "5.10.0"

android {
compileSdk 33

defaultConfig {
minSdkVersion 24
targetSdkVersion 33
versionCode 5_09_00
versionCode 5_10_00
versionName version

consumerProguardFiles 'consumer-proguard-rules.pro'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.linecorp.android.security.encryption

import android.util.Base64

class CipherData(
val encryptedData: ByteArray,
val initialVector: ByteArray,
val hmacValue: ByteArray,
) {
fun encodeToBase64String(): String =
listOf(encryptedData, initialVector, hmacValue)
.joinToString(SEPARATOR) { it.encodeBase64() }

companion object {
private const val SEPARATOR = ";"
private const val SIZE_DATA_TYPES = 3

fun decodeFromBase64String(cipherDataBase64String: String): CipherData {
val parts = cipherDataBase64String.split(SEPARATOR)
require(parts.size == SIZE_DATA_TYPES) {
"Failed to split encrypted text `$cipherDataBase64String`"
}

return CipherData(
encryptedData = parts[0].decodeBase64(),
initialVector = parts[1].decodeBase64(),
hmacValue = parts[2].decodeBase64()
)
}
}
}

private fun ByteArray.encodeBase64(): String = Base64.encodeToString(this, Base64.NO_WRAP)

private fun String.decodeBase64(): ByteArray = Base64.decode(this, Base64.NO_WRAP)
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package com.linecorp.android.security.encryption

import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
import android.security.keystore.KeyProperties.PURPOSE_SIGN
import android.security.keystore.KeyProperties.PURPOSE_VERIFY
import java.security.KeyStore
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.Mac
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec

/**
* AES cipher by AndroidKeyStore
*/
class StringAesCipher : StringCipher {
private val keyStore: KeyStore by lazy {
KeyStore.getInstance(ANDROID_KEY_STORE).also {
it.load(null)
}
}

private lateinit var hmac: Mac

override fun initialize(context: Context) {
if (::hmac.isInitialized) {
return
}

synchronized(this) {
getAesSecretKey()
val integrityKey = getIntegrityKey()

hmac = Mac.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256).apply {
init(integrityKey)
}
}
}

override fun encrypt(context: Context, plainText: String): String {
synchronized(this) {
initialize(context)

try {
val secretKey = getAesSecretKey()

val cipher = Cipher.getInstance(TRANSFORMATION_FORMAT).apply {
init(Cipher.ENCRYPT_MODE, secretKey)
}
val encryptedData: ByteArray = cipher.doFinal(plainText.toByteArray())

return CipherData(
encryptedData = encryptedData,
initialVector = cipher.iv,
hmacValue = hmac.calculateHmacValue(encryptedData, cipher.iv)
).encodeToBase64String()
} catch (e: Exception) {
throw EncryptionException("Failed to encrypt", e)
}
}
}

override fun decrypt(context: Context, cipherText: String): String {
synchronized(this) {
try {
val secretKey = getAesSecretKey()

val cipherData = CipherData.decodeFromBase64String(cipherText)

cipherData.verifyHmacValue(hmac)

val ivSpec = IvParameterSpec(cipherData.initialVector)

return Cipher.getInstance(TRANSFORMATION_FORMAT)
.apply { init(Cipher.DECRYPT_MODE, secretKey, ivSpec) }
.run { doFinal(cipherData.encryptedData) }
.let {
String(it)
}
} catch (e: Exception) {
throw EncryptionException("Failed to decrypt", e)
}
}
}

private fun getAesSecretKey(): SecretKey {
return if (keyStore.containsAlias(AES_KEY_ALIAS)) {
val secretKeyEntry =
keyStore.getEntry(AES_KEY_ALIAS, null) as KeyStore.SecretKeyEntry

secretKeyEntry.secretKey
} else {
createAesKey()
}
}

private fun getIntegrityKey(): SecretKey {
return if (keyStore.containsAlias(INTEGRITY_KEY_ALIAS)) {
val secretKeyEntry =
keyStore.getEntry(INTEGRITY_KEY_ALIAS, null) as KeyStore.SecretKeyEntry

secretKeyEntry.secretKey
} else {
createIntegrityKey()
}
}

/**
* Create a new AES key in the Android KeyStore. This key will be used for
* encrypting and decrypting data. The key is generated with a size of 256 bits,
* using the CBC block mode and PKCS7 padding.
*/
private fun createAesKey(): SecretKey {
val keyGenerator = KeyGenerator
.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
AES_KEY_ALIAS,
PURPOSE_ENCRYPT or PURPOSE_DECRYPT
)
.setKeySize(KEY_SIZE_IN_BIT)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.build()

keyGenerator.run {
init(keyGenParameterSpec)
return generateKey()
}
}

private fun createIntegrityKey(): SecretKey {
val keyGenerator = KeyGenerator
.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEY_STORE)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
INTEGRITY_KEY_ALIAS,
PURPOSE_SIGN or PURPOSE_VERIFY
)
.build()

keyGenerator.run {
init(keyGenParameterSpec)
return generateKey()
}
}

private fun Mac.calculateHmacValue(
encryptedData: ByteArray,
initialVector: ByteArray
): ByteArray = doFinal(encryptedData + initialVector)

/**
* Validate the HMAC value
*
* @throws SecurityException if the HMAC value doesn't match with [encryptedData]
*/
private fun CipherData.verifyHmacValue(mac: Mac) {
val expectedHmacValue: ByteArray = mac.calculateHmacValue(
encryptedData = encryptedData,
initialVector = initialVector
)

if (!MessageDigest.isEqual(expectedHmacValue, hmacValue)) {
throw SecurityException("Cipher text has been tampered with.")
}
}

companion object {
private const val AES_KEY_ALIAS =
"com.linecorp.android.security.encryption.StringAesCipher"

private const val INTEGRITY_KEY_ALIAS =
"com.linecorp.android.security.encryption.StringAesCipher.INTEGRITY_KEY"

private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_SIZE_IN_BIT = 256

private const val TRANSFORMATION_FORMAT =
KeyProperties.KEY_ALGORITHM_AES +
"/${KeyProperties.BLOCK_MODE_CBC}" +
"/${KeyProperties.ENCRYPTION_PADDING_PKCS7}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.linecorp.android.security.encryption

import android.content.Context

interface StringCipher {

fun initialize(context: Context)

fun encrypt(context: Context, plainText: String): String

fun decrypt(context: Context, cipherText: String): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
import android.content.SharedPreferences;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
import android.util.Base64;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Base64;

import java.io.UnsupportedEncodingException;
import java.security.InvalidAlgorithmParameterException;
Expand Down Expand Up @@ -45,9 +46,12 @@
* Either first access of {@link #encrypt(Context, String)}, {@link #decrypt(Context, String)} or
* {@link #initialize(Context)} is very slow because there is secret key generation with PBKDF2.
* We recommend that you initialize an instance of this class beforehand and cache it.
*
* @deprecated This class is obsolete. Use {@link StringAesCipher} as its replacement.
*/
@Deprecated
@WorkerThread
public class StringCipher {
public class StringCipherDeprecated implements StringCipher {
// for PBKDF
private static final int DEFAULT_ITERATIONS = 10000;

Expand Down Expand Up @@ -79,7 +83,7 @@ public class StringCipher {
@Nullable
private SecretKeys secretKeys;

public StringCipher(@NonNull String sharedPreferenceName) {
public StringCipherDeprecated(@NonNull String sharedPreferenceName) {
this(sharedPreferenceName, DEFAULT_ITERATIONS, false);
}

Expand All @@ -92,7 +96,7 @@ public StringCipher(@NonNull String sharedPreferenceName) {
Note : This field should always be false as it is deprecated and
returns UNKNOWN in some cases from Android SDK >= 27
*/
public StringCipher(
public StringCipherDeprecated(
@NonNull String sharedPreferenceName,
int pbkdf2IterationCount,
boolean isSerialIncludedInDevicePackageSpecificId) {
Expand All @@ -111,6 +115,7 @@ public StringCipher(
}
}

@Override
public void initialize(@NonNull Context context) {
synchronized (syncObject) {
if (secretKeys == null) {
Expand All @@ -119,6 +124,7 @@ public void initialize(@NonNull Context context) {
}
}

@Override
@NonNull
public String encrypt(@NonNull Context context, @NonNull String plainText) {
synchronized (syncObject) {
Expand Down Expand Up @@ -160,12 +166,13 @@ public String encrypt(@NonNull Context context, @NonNull String plainText) {
}
}

@Override
@NonNull
public String decrypt(@NonNull Context context, @NonNull String b64CipherText) {
public String decrypt(@NonNull Context context, @NonNull String cipherText) {
synchronized (syncObject) {
initialize(context);
try {
byte[] cipherTextAndMac = Base64.decode(b64CipherText, Base64.DEFAULT);
byte[] cipherTextAndMac = Base64.decode(cipherText, Base64.DEFAULT);
// get mac, last 32 bytes
int idx = cipherTextAndMac.length - HMAC_SIZE_IN_BYTE;
byte[] mac = Arrays.copyOfRange(cipherTextAndMac, idx, cipherTextAndMac.length);
Expand Down Expand Up @@ -219,7 +226,7 @@ private SecretKeys getSecretKeys(@NonNull Context context) {
SecretKey encryptionKey = new SecretKeySpec(
Arrays.copyOfRange(keyBytes, 0, AES_KEY_SIZE_IN_BIT / 8), "AES");
SecretKey integrityKey = new SecretKeySpec(
Arrays.copyOfRange(keyBytes, HMAC_KEY_SIZE_IN_BIT / 8, keyBytes.length), "HmacSHA256");
Arrays.copyOfRange(keyBytes, AES_KEY_SIZE_IN_BIT / 8, keyBytes.length), "HmacSHA256");
return new SecretKeys(encryptionKey, integrityKey);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.linecorp.linesdk.FriendSortField;
import com.linecorp.linesdk.GetFriendsResponse;
import com.linecorp.linesdk.GetGroupsResponse;
Expand Down Expand Up @@ -29,9 +32,6 @@

import java.util.List;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

/**
* Implementation of {@link LineApiClient}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
import android.content.SharedPreferences;
import android.text.TextUtils;

import com.linecorp.android.security.encryption.EncryptionException;
import com.linecorp.android.security.encryption.StringCipher;
import com.linecorp.linesdk.utils.ObjectUtils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.linecorp.android.security.encryption.EncryptionException;
import com.linecorp.android.security.encryption.StringCipher;
import com.linecorp.linesdk.utils.ObjectUtils;

/**
* Class to cache {@link InternalAccessToken}.
*/
Expand Down
Loading