mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-06 19:01:14 +00:00
Encrypt stored secrets with a keystore AES-GCM key and migrate old values on read
Secrets and the PIN were AES-CBC without authentication, under a random key kept in shared preferences and wrapped by a keystore RSA key with PKCS#1 v1.5 padding. They are now AES-256-GCM under a key that never leaves the Android keystore, stored as "v2:" + Base64. Old values stay readable through a read-only decrypter of the previous scheme; nothing is written in it any more and no new RSA key is created. A legacy value is re-encrypted when it is read, and replaced only once the new copy decrypts to the same text. A value that cannot be decrypted now reads as absent and is left untouched, where decrypt() used to return "" and let a caller treat it as a value.
This commit is contained in:
1 parent
35c71062f0
commit
f52437e021
7 files changed
+322
-233
No files matched your search
+40
-3
@@ -1,19 +1,37 @@
|
||||
package io.novafoundation.nova.common.data.storage.encrypt
|
||||
|
||||
import android.util.Log
|
||||
import io.novafoundation.nova.common.data.storage.Preferences
|
||||
|
||||
class EncryptedPreferencesImpl(
|
||||
private val preferences: Preferences,
|
||||
private val encryptionUtil: EncryptionUtil
|
||||
private val encryptionUtil: EncryptionUtil,
|
||||
private val logError: (String, Throwable?) -> Unit = { message, error -> Log.e(LOG_TAG, message, error) },
|
||||
) : EncryptedPreferences {
|
||||
|
||||
override fun putEncryptedString(field: String, value: String) {
|
||||
preferences.putString(field, encryptionUtil.encrypt(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* A value that cannot be decrypted reads as absent and stays stored as it is. A legacy value is
|
||||
* re-encrypted in the current scheme, and replaced only once the new copy decrypts to the same text.
|
||||
*/
|
||||
override fun getDecryptedString(field: String): String? {
|
||||
val encryptedString = preferences.getString(field)
|
||||
return encryptedString?.let { encryptionUtil.decrypt(it) }
|
||||
val stored = preferences.getString(field) ?: return null
|
||||
|
||||
val value = try {
|
||||
encryptionUtil.decrypt(stored)
|
||||
} catch (e: UndecryptableValueException) {
|
||||
logError("Cannot decrypt $field", e)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!encryptionUtil.isCurrent(stored)) {
|
||||
reEncrypt(field, value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
override fun hasKey(field: String): Boolean {
|
||||
@@ -23,4 +41,23 @@ class EncryptedPreferencesImpl(
|
||||
override fun removeKey(field: String) {
|
||||
preferences.removeField(field)
|
||||
}
|
||||
|
||||
private fun reEncrypt(field: String, value: String) {
|
||||
val reEncrypted = try {
|
||||
encryptionUtil.encrypt(value).takeIf { encryptionUtil.decrypt(it) == value }
|
||||
} catch (e: Exception) {
|
||||
logError("Cannot re-encrypt $field", e)
|
||||
return
|
||||
}
|
||||
|
||||
if (reEncrypted != null) {
|
||||
preferences.putString(field, reEncrypted)
|
||||
} else {
|
||||
logError("Re-encrypted $field does not decrypt to its value", null)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOG_TAG = "EncryptedPreferences"
|
||||
}
|
||||
}
|
||||
+24
-229
@@ -1,246 +1,41 @@
|
||||
package io.novafoundation.nova.common.data.storage.encrypt
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyPairGeneratorSpec
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import org.bouncycastle.util.Arrays
|
||||
import org.bouncycastle.util.encoders.Base64
|
||||
import java.math.BigInteger
|
||||
import java.security.InvalidAlgorithmParameterException
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.Key
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.spec.AlgorithmParameterSpec
|
||||
import java.util.Calendar
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.IllegalBlockSizeException
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.NoSuchPaddingException
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import javax.inject.Inject
|
||||
import javax.security.auth.x500.X500Principal
|
||||
|
||||
class EncryptionUtil @Inject constructor(
|
||||
private val context: Context
|
||||
/**
|
||||
* Text form of encrypted preference values. Current values are `v2:` and Base64 of [current]'s output;
|
||||
* anything else is the legacy scheme and is read through [legacy].
|
||||
*/
|
||||
class EncryptionUtil(
|
||||
private val current: ValueCipher,
|
||||
private val legacy: ValueDecrypter,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val RSA = "RSA"
|
||||
private const val AES = "AES"
|
||||
private const val KEY_STORE_PROVIDER = "AndroidKeyStore"
|
||||
private const val TRANSFORMATION = "RSA/ECB/PKCS1Padding"
|
||||
private const val KEY_ALIAS = "key_alias"
|
||||
private const val BLOCK_SIZE = 16
|
||||
private const val AES_KEY_LENGTH = 256
|
||||
private var second = false
|
||||
|
||||
private var privateKey: PrivateKey? = null
|
||||
private var publicKey: PublicKey? = null
|
||||
|
||||
private const val SECRET_KEY = "secret_key"
|
||||
private val secureRandom = SecureRandom()
|
||||
private var keyStore: KeyStore? = null
|
||||
fun encrypt(cleartext: String): String {
|
||||
return CURRENT_PREFIX + Base64.toBase64String(current.encrypt(cleartext.toByteArray()))
|
||||
}
|
||||
|
||||
init {
|
||||
initKeystore()
|
||||
}
|
||||
|
||||
fun getPrerenceAesKey(): Key {
|
||||
val secretKey: SecretKey
|
||||
val encryptedKey = context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).getString(SECRET_KEY, "")
|
||||
if (encryptedKey!!.isEmpty()) {
|
||||
val keyGenerator = KeyGenerator.getInstance(AES)
|
||||
keyGenerator.init(AES_KEY_LENGTH, secureRandom)
|
||||
secretKey = keyGenerator.generateKey()
|
||||
context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).edit().putString(SECRET_KEY, encryptRsa(secretKey.encoded)).apply()
|
||||
} else {
|
||||
val key = decryptRsa(encryptedKey)
|
||||
secretKey = SecretKeySpec(key, 0, key!!.size, AES)
|
||||
}
|
||||
return secretKey
|
||||
}
|
||||
|
||||
private fun initKeystore() {
|
||||
try {
|
||||
keyStore = KeyStore.getInstance(KEY_STORE_PROVIDER)
|
||||
keyStore!!.load(null)
|
||||
|
||||
if (keyStore!!.getKey(KEY_ALIAS, null) == null) {
|
||||
createKeys()
|
||||
}
|
||||
|
||||
privateKey = keyStore!!.getKey(KEY_ALIAS, null) as PrivateKey
|
||||
publicKey = keyStore!!.getCertificate(KEY_ALIAS).publicKey
|
||||
} catch (e: Exception) {
|
||||
if (!second) {
|
||||
second = true
|
||||
initKeystore()
|
||||
}
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createKeys() {
|
||||
val startDate = Calendar.getInstance()
|
||||
val endDate = Calendar.getInstance()
|
||||
endDate.add(Calendar.YEAR, 25)
|
||||
|
||||
val spec: AlgorithmParameterSpec
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
spec = KeyPairGeneratorSpec.Builder(context)
|
||||
.setAlias(KEY_ALIAS)
|
||||
.setSubject(X500Principal("CN=Sora"))
|
||||
.setSerialNumber(BigInteger.ONE)
|
||||
.setStartDate(startDate.time)
|
||||
.setEndDate(endDate.time)
|
||||
.build()
|
||||
} else {
|
||||
spec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setCertificateSubject(X500Principal("CN=Sora"))
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
|
||||
.setCertificateSerialNumber(BigInteger.ONE)
|
||||
.setCertificateNotBefore(startDate.time)
|
||||
.setCertificateNotAfter(endDate.time)
|
||||
.build()
|
||||
}
|
||||
val keyPairGenerator = KeyPairGenerator.getInstance(RSA, KEY_STORE_PROVIDER)
|
||||
keyPairGenerator.initialize(spec)
|
||||
keyPairGenerator.generateKeyPair()
|
||||
}
|
||||
|
||||
fun encrypt(cleartext: String?): String {
|
||||
if (cleartext != null && cleartext.isNotEmpty()) {
|
||||
try {
|
||||
return encrypt(getPrerenceAesKey().encoded, cleartext)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun encrypt(key: ByteArray, cleartext: String): String {
|
||||
try {
|
||||
val result = encrypt(key, cleartext.toByteArray())
|
||||
return Base64.toBase64String(result)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun decrypt(encryptedBase64: String): String {
|
||||
try {
|
||||
return decrypt(getPrerenceAesKey().encoded, encryptedBase64)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printStackTrace()
|
||||
/** @throws UndecryptableValueException */
|
||||
fun decrypt(stored: String): String {
|
||||
val clear = when {
|
||||
isCurrent(stored) -> current.decrypt(decodeBase64(stored.removePrefix(CURRENT_PREFIX)))
|
||||
// The legacy scheme stored an empty string as is
|
||||
stored.isEmpty() -> ByteArray(0)
|
||||
else -> legacy.decrypt(decodeBase64(stored))
|
||||
}
|
||||
|
||||
return ""
|
||||
return String(clear)
|
||||
}
|
||||
|
||||
fun decrypt(key: ByteArray, encryptedBase64: String): String {
|
||||
try {
|
||||
val encrypted = Base64.decode(encryptedBase64)
|
||||
val result = decrypt(key, encrypted)
|
||||
return String(result)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
fun isCurrent(stored: String): Boolean = stored.startsWith(CURRENT_PREFIX)
|
||||
|
||||
return ""
|
||||
private fun decodeBase64(text: String): ByteArray = try {
|
||||
Base64.decode(text)
|
||||
} catch (e: Exception) {
|
||||
throw UndecryptableValueException(e)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun encrypt(key: ByteArray, clear: ByteArray): ByteArray {
|
||||
val skeySpec = SecretKeySpec(key, "AES")
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, skeySpec, IvParameterSpec(generateIVBytes()), secureRandom)
|
||||
return Arrays.concatenate(cipher.iv, cipher.doFinal(clear))
|
||||
}
|
||||
|
||||
@Throws(
|
||||
NoSuchPaddingException::class,
|
||||
NoSuchAlgorithmException::class,
|
||||
InvalidAlgorithmParameterException::class,
|
||||
InvalidKeyException::class,
|
||||
BadPaddingException::class,
|
||||
IllegalBlockSizeException::class
|
||||
)
|
||||
private fun decrypt(key: ByteArray, encrypted: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(key, "AES"),
|
||||
IvParameterSpec(Arrays.copyOfRange(encrypted, 0, BLOCK_SIZE)),
|
||||
secureRandom
|
||||
)
|
||||
return cipher.doFinal(Arrays.copyOfRange(encrypted, BLOCK_SIZE, encrypted.size))
|
||||
}
|
||||
|
||||
private fun encryptRsa(input: ByteArray): String {
|
||||
val cipher: Cipher
|
||||
|
||||
try {
|
||||
cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||
return Base64.toBase64String(cipher.doFinal(input))
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: NoSuchPaddingException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: InvalidKeyException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: BadPaddingException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: IllegalBlockSizeException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun decryptRsa(encrypted: String): ByteArray? {
|
||||
val cipher: Cipher
|
||||
|
||||
try {
|
||||
cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||
return cipher.doFinal(Base64.decode(encrypted))
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: NoSuchPaddingException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: InvalidKeyException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: BadPaddingException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: IllegalBlockSizeException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun generateIVBytes(): ByteArray {
|
||||
val ivBytes = ByteArray(BLOCK_SIZE)
|
||||
secureRandom.nextBytes(ivBytes)
|
||||
return ivBytes
|
||||
private companion object {
|
||||
const val CURRENT_PREFIX = "v2:"
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package io.novafoundation.nova.common.data.storage.encrypt
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* AES-256-GCM under a key that never leaves the Android keystore. Output is the 12-byte IV the
|
||||
* keystore chose followed by the ciphertext and its 16-byte tag.
|
||||
*/
|
||||
class KeystoreAesGcmCipher(private val alias: String) : ValueCipher {
|
||||
|
||||
override fun encrypt(clear: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||
|
||||
return cipher.iv + cipher.doFinal(clear)
|
||||
}
|
||||
|
||||
override fun decrypt(sealed: ByteArray): ByteArray = try {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed, 0, IV_SIZE))
|
||||
|
||||
cipher.doFinal(sealed, IV_SIZE, sealed.size - IV_SIZE)
|
||||
} catch (e: Exception) {
|
||||
throw UndecryptableValueException(e)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun key(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(KEY_STORE).apply { load(null) }
|
||||
(keyStore.getKey(alias, null) as SecretKey?)?.let { return it }
|
||||
|
||||
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE)
|
||||
.apply { init(spec) }
|
||||
.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_STORE = "AndroidKeyStore"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val IV_SIZE = 12
|
||||
const val TAG_BITS = 128
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package io.novafoundation.nova.common.data.storage.encrypt
|
||||
|
||||
import android.content.Context
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import org.bouncycastle.util.encoders.Base64
|
||||
|
||||
/**
|
||||
* Reads values written before the keystore AES-GCM key: AES-256-CBC under a random key that is kept in
|
||||
* shared preferences wrapped by a keystore RSA key (PKCS#1). Read-only, and only so those values can be
|
||||
* re-encrypted; nothing is written in this scheme anymore and no new RSA key is created.
|
||||
*/
|
||||
class LegacyRsaWrappedAesDecrypter(private val context: Context) : ValueDecrypter {
|
||||
|
||||
private val aesKey by lazy(::unwrapAesKey)
|
||||
|
||||
override fun decrypt(sealed: ByteArray): ByteArray = try {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(sealed, 0, BLOCK_SIZE))
|
||||
|
||||
cipher.doFinal(sealed, BLOCK_SIZE, sealed.size - BLOCK_SIZE)
|
||||
} catch (e: Exception) {
|
||||
throw UndecryptableValueException(e)
|
||||
}
|
||||
|
||||
private fun unwrapAesKey(): ByteArray {
|
||||
val wrapped = context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).getString(SECRET_KEY, null)
|
||||
?: error("No wrapped legacy key")
|
||||
val keyStore = KeyStore.getInstance(KEY_STORE).apply { load(null) }
|
||||
val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey? ?: error("No legacy RSA key")
|
||||
|
||||
// PKCS#1 v1.5 is what these keys were wrapped with; decrypting is the only use left
|
||||
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey)
|
||||
|
||||
return cipher.doFinal(Base64.decode(wrapped))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_STORE = "AndroidKeyStore"
|
||||
const val KEY_ALIAS = "key_alias"
|
||||
const val SECRET_KEY = "secret_key"
|
||||
const val BLOCK_SIZE = 16
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.novafoundation.nova.common.data.storage.encrypt
|
||||
|
||||
/** Decrypts a stored value, throwing [UndecryptableValueException] when it cannot. */
|
||||
interface ValueDecrypter {
|
||||
|
||||
fun decrypt(sealed: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
interface ValueCipher : ValueDecrypter {
|
||||
|
||||
fun encrypt(clear: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
class UndecryptableValueException(cause: Throwable) : Exception("Stored value cannot be decrypted", cause)
|
||||
@@ -47,6 +47,8 @@ import io.novafoundation.nova.common.data.storage.PreferencesImpl
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferencesImpl
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.EncryptionUtil
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.KeystoreAesGcmCipher
|
||||
import io.novafoundation.nova.common.data.storage.encrypt.LegacyRsaWrappedAesDecrypter
|
||||
import io.novafoundation.nova.common.di.scope.ApplicationScope
|
||||
import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor
|
||||
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
|
||||
@@ -207,7 +209,10 @@ class CommonModule {
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun provideEncryptionUtil(context: Context): EncryptionUtil {
|
||||
return EncryptionUtil(context)
|
||||
return EncryptionUtil(
|
||||
current = KeystoreAesGcmCipher(alias = "data_key_v2"),
|
||||
legacy = LegacyRsaWrappedAesDecrypter(context),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package io.novafoundation.nova.common.data.storage.encrypt
|
||||
|
||||
import io.novafoundation.nova.common.data.storage.Preferences
|
||||
import org.bouncycastle.util.encoders.Base64
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class EncryptedPreferencesImplTest {
|
||||
|
||||
private val preferences = MapPreferences()
|
||||
private val legacy = JvmAesCbc()
|
||||
private val errors = mutableListOf<String>()
|
||||
|
||||
private fun encryptedPreferences(current: ValueCipher = JvmAesGcm()) =
|
||||
EncryptedPreferencesImpl(preferences, EncryptionUtil(current, legacy), logError = { message, _ -> errors += message })
|
||||
|
||||
@Test
|
||||
fun `a legacy value is read and re-encrypted in the current scheme`() {
|
||||
preferences.putString(FIELD, legacy.encryptToText("seed words"))
|
||||
val encryptedPreferences = encryptedPreferences()
|
||||
|
||||
assertEquals("seed words", encryptedPreferences.getDecryptedString(FIELD))
|
||||
assertTrue(preferences.getString(FIELD)!!.startsWith("v2:"))
|
||||
assertEquals("seed words", encryptedPreferences.getDecryptedString(FIELD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a legacy empty string stays empty`() {
|
||||
preferences.putString(FIELD, "")
|
||||
|
||||
assertEquals("", encryptedPreferences().getDecryptedString(FIELD))
|
||||
assertTrue(preferences.getString(FIELD)!!.startsWith("v2:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an undecryptable legacy value reads as absent and is left as it is`() {
|
||||
val corrupt = Base64.toBase64String(ByteArray(48) { 7 })
|
||||
preferences.putString(FIELD, corrupt)
|
||||
|
||||
assertNull(encryptedPreferences().getDecryptedString(FIELD))
|
||||
assertEquals(corrupt, preferences.getString(FIELD))
|
||||
assertEquals(1, errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tampered current value reads as absent`() {
|
||||
val encryptedPreferences = encryptedPreferences()
|
||||
encryptedPreferences.putEncryptedString(FIELD, "1234")
|
||||
val stored = preferences.getString(FIELD)!!
|
||||
val bytes = Base64.decode(stored.removePrefix("v2:")).also { it[it.size - 1] = (it[it.size - 1] + 1).toByte() }
|
||||
preferences.putString(FIELD, "v2:" + Base64.toBase64String(bytes))
|
||||
|
||||
assertNull(encryptedPreferences.getDecryptedString(FIELD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a legacy value is kept when its re-encryption does not decrypt back`() {
|
||||
val legacyText = legacy.encryptToText("seed words")
|
||||
preferences.putString(FIELD, legacyText)
|
||||
|
||||
assertEquals("seed words", encryptedPreferences(current = GarblingCipher()).getDecryptedString(FIELD))
|
||||
assertEquals(legacyText, preferences.getString(FIELD))
|
||||
assertEquals(1, errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `values are written in the current scheme`() {
|
||||
encryptedPreferences().putEncryptedString(FIELD, "1234")
|
||||
|
||||
assertTrue(preferences.getString(FIELD)!!.startsWith("v2:"))
|
||||
}
|
||||
|
||||
private class MapPreferences : Preferences by Mockito.mock(Preferences::class.java) {
|
||||
private val values = mutableMapOf<String, String?>()
|
||||
|
||||
override fun contains(field: String) = field in values
|
||||
override fun putString(field: String, value: String?) = values.set(field, value)
|
||||
override fun getString(field: String): String? = values[field]
|
||||
override fun removeField(field: String) {
|
||||
values.remove(field)
|
||||
}
|
||||
}
|
||||
|
||||
private class JvmAesGcm : ValueCipher {
|
||||
private val key = SecretKeySpec(ByteArray(32) { 1 }, "AES")
|
||||
|
||||
override fun encrypt(clear: ByteArray): ByteArray {
|
||||
val iv = ByteArray(12).also(SecureRandom()::nextBytes)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) }
|
||||
return iv + cipher.doFinal(clear)
|
||||
}
|
||||
|
||||
override fun decrypt(sealed: ByteArray): ByteArray = try {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, 12)) }
|
||||
cipher.doFinal(sealed, 12, sealed.size - 12)
|
||||
} catch (e: Exception) {
|
||||
throw UndecryptableValueException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** The legacy scheme without its RSA wrapping of the key. */
|
||||
private class JvmAesCbc : ValueDecrypter {
|
||||
private val key = SecretKeySpec(ByteArray(32) { 2 }, "AES")
|
||||
|
||||
fun encryptToText(clear: String): String {
|
||||
val iv = ByteArray(16).also(SecureRandom()::nextBytes)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) }
|
||||
return Base64.toBase64String(iv + cipher.doFinal(clear.toByteArray()))
|
||||
}
|
||||
|
||||
override fun decrypt(sealed: ByteArray): ByteArray = try {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { init(Cipher.DECRYPT_MODE, key, IvParameterSpec(sealed, 0, 16)) }
|
||||
cipher.doFinal(sealed, 16, sealed.size - 16)
|
||||
} catch (e: Exception) {
|
||||
throw UndecryptableValueException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private class GarblingCipher : ValueCipher {
|
||||
override fun encrypt(clear: ByteArray) = clear
|
||||
override fun decrypt(sealed: ByteArray) = sealed + 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FIELD = "pin_code"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user