diff --git a/KeychainExample/e2e/testCases/accessControlTest.spec.js b/KeychainExample/e2e/testCases/accessControlTest.spec.js index 09f8f5d0..9329b7b4 100644 --- a/KeychainExample/e2e/testCases/accessControlTest.spec.js +++ b/KeychainExample/e2e/testCases/accessControlTest.spec.js @@ -1,10 +1,14 @@ -import { by, device, element, expect, waitFor } from 'detox'; -import { matchLoadInfo } from '../utils/matchLoadInfo'; +import { by, device, element, expect } from 'detox'; import { waitForAuthValidity, enterBiometrics, enterPasscode, } from '../utils/authHelpers'; +import { + expectCredentialsLoadedMessage, + expectCredentialsSavedMessage, + expectCredentialsResetMessage, +} from '../utils/statusMessageHelpers'; describe('Access Control', () => { beforeEach(async () => { @@ -28,22 +32,21 @@ describe('Access Control', () => { await enterPasscode(); // Hide keyboard if open await element(by.text('Keychain Example')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(4000); + await expectCredentialsSavedMessage(); await waitForAuthValidity(); await element(by.text('Load')).tap(); await enterPasscode(); // Hide keyboard if open await element(by.text('Keychain Example')).tap(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernamePasscode', 'testPasswordPasscode', 'KeystoreAESGCM' ); } ); + it( ' should save and retrieve username and password with biometrics - ' + type, @@ -69,15 +72,16 @@ describe('Access Control', () => { await element(by.text('Save')).tap(); await enterBiometrics(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await waitForAuthValidity(); await element(by.text('Load')).tap(); await enterBiometrics(); - await matchLoadInfo('testUsernameBiometrics', 'testPasswordBiometrics'); + await expectCredentialsLoadedMessage( + 'testUsernameBiometrics', + 'testPasswordBiometrics' + ); } ); @@ -91,7 +95,10 @@ describe('Access Control', () => { ).toBeVisible(); await element(by.text('Load')).tap(); await enterBiometrics(); - await matchLoadInfo('testUsernameBiometrics', 'testPasswordBiometrics'); + await expectCredentialsLoadedMessage( + 'testUsernameBiometrics', + 'testPasswordBiometrics' + ); } ); @@ -112,11 +119,12 @@ describe('Access Control', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); - await matchLoadInfo('testUsernameAny', 'testPasswordAny'); + await expectCredentialsLoadedMessage( + 'testUsernameAny', + 'testPasswordAny' + ); } ); @@ -129,7 +137,10 @@ describe('Access Control', () => { element(by.text('hasGenericPassword: true')) ).toBeVisible(); await element(by.text('Load')).tap(); - await matchLoadInfo('testUsernameAny', 'testPasswordAny'); + await expectCredentialsLoadedMessage( + 'testUsernameAny', + 'testPasswordAny' + ); } ); }); @@ -139,6 +150,6 @@ describe('Access Control', () => { // Hide keyboard await element(by.text('Reset')).tap(); - await expect(element(by.text(/^Credentials Reset!$/))).toBeVisible(); + await expectCredentialsResetMessage(); }); }); diff --git a/KeychainExample/e2e/testCases/securityLevelTest.spec.js b/KeychainExample/e2e/testCases/securityLevelTest.spec.js index 51f2f212..3f51a2bb 100644 --- a/KeychainExample/e2e/testCases/securityLevelTest.spec.js +++ b/KeychainExample/e2e/testCases/securityLevelTest.spec.js @@ -1,5 +1,9 @@ -import { by, device, element, expect, waitFor } from 'detox'; -import { matchLoadInfo } from '../utils/matchLoadInfo'; +import { by, element, expect, device } from 'detox'; +import { + expectCredentialsLoadedMessage, + expectCredentialsSavedMessage, + expectCredentialsResetMessage, +} from '../utils/statusMessageHelpers'; describe(':android:Security Level', () => { beforeEach(async () => { @@ -19,11 +23,9 @@ describe(':android:Security Level', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameAny', 'testPasswordAny', undefined, @@ -46,11 +48,9 @@ describe(':android:Security Level', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameSoftware', 'testPasswordSoftware', undefined, @@ -74,11 +74,9 @@ describe(':android:Security Level', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameHardware', 'testPasswordHardware', undefined, @@ -93,6 +91,6 @@ describe(':android:Security Level', () => { // Hide keyboard await element(by.text('Reset')).tap(); - await expect(element(by.text(/^Credentials Reset!$/))).toBeVisible(); + await expectCredentialsResetMessage(); }); }); diff --git a/KeychainExample/e2e/testCases/storageTypesTest.spec.js b/KeychainExample/e2e/testCases/storageTypesTest.spec.js index 6e071d49..d6f09557 100644 --- a/KeychainExample/e2e/testCases/storageTypesTest.spec.js +++ b/KeychainExample/e2e/testCases/storageTypesTest.spec.js @@ -1,7 +1,12 @@ -import { by, device, element, expect, waitFor } from 'detox'; -import { matchLoadInfo } from '../utils/matchLoadInfo'; +import { by, element, expect, device } from 'detox'; import { enterBiometrics, waitForAuthValidity } from '../utils/authHelpers'; +import { + expectCredentialsLoadedMessage, + expectCredentialsSavedMessage, + expectCredentialsResetMessage, +} from '../utils/statusMessageHelpers'; + describe(':android:Storage Types', () => { beforeEach(async () => { await device.launchApp({ newInstance: true }); @@ -20,11 +25,9 @@ describe(':android:Storage Types', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameAESCBC', 'testPasswordAESCBC', 'KeystoreAESCBC', @@ -46,13 +49,11 @@ describe(':android:Storage Types', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); await enterBiometrics(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await waitForAuthValidity(); await element(by.text('Load')).tap(); await enterBiometrics(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameAESGCM', 'testPasswordAESGCM', 'KeystoreAESGCM', @@ -79,11 +80,9 @@ describe(':android:Storage Types', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(3000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameAESGCMNoAuth', 'testPasswordAESGCMNoAuth', 'KeystoreAESGCM_NoAuth', @@ -105,15 +104,10 @@ describe(':android:Storage Types', () => { await expect(element(by.text('Save'))).toBeVisible(); await element(by.text('Save')).tap(); - await waitFor(element(by.text(/^Credentials saved! .*$/))) - .toExist() - .withTimeout(5000); + await expectCredentialsSavedMessage(); await element(by.text('Load')).tap(); await enterBiometrics(); - await waitFor(element(by.text(/^Credentials loaded! .*$/))) - .toExist() - .withTimeout(5000); - await matchLoadInfo( + await expectCredentialsLoadedMessage( 'testUsernameRSA', 'testPasswordRSA', 'KeystoreRSAECB', @@ -127,6 +121,6 @@ describe(':android:Storage Types', () => { // Hide keyboard await element(by.text('Reset')).tap(); - await expect(element(by.text(/^Credentials Reset!$/))).toExist(); + await expectCredentialsResetMessage(); }); }); diff --git a/KeychainExample/e2e/utils/authHelpers.js b/KeychainExample/e2e/utils/authHelpers.ts similarity index 75% rename from KeychainExample/e2e/utils/authHelpers.js rename to KeychainExample/e2e/utils/authHelpers.ts index 943a5223..c81cc46d 100644 --- a/KeychainExample/e2e/utils/authHelpers.js +++ b/KeychainExample/e2e/utils/authHelpers.ts @@ -1,9 +1,9 @@ -import { device } from 'detox'; import cp from 'child_process'; +import { device } from 'detox'; // Wait for 5 seconds to ensure auth validity period has expired export const waitForAuthValidity = async () => { - await new Promise((resolve) => setTimeout(resolve, 5500)); // Added 500ms buffer + await new Promise((resolve) => setTimeout(resolve, 5500)); // buffer needed for auth validity period }; export const enterBiometrics = async () => { @@ -12,16 +12,14 @@ export const enterBiometrics = async () => { if (device.getPlatform() === 'android') { await new Promise((resolve) => setTimeout(resolve, 1000)); cp.spawnSync('adb', ['-e', 'emu', 'finger', 'touch', '1']); - await new Promise((resolve) => setTimeout(resolve, 500)); } }; export const enterPasscode = async () => { if (device.getPlatform() === 'android') { - await new Promise((resolve) => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1000)); cp.spawnSync('adb', ['shell', 'input', 'text', '1111']); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); cp.spawnSync('adb', ['shell', 'input', 'keyevent', '66']); - await new Promise((resolve) => setTimeout(resolve, 1500)); } }; diff --git a/KeychainExample/e2e/utils/detoxHelpers.ts b/KeychainExample/e2e/utils/detoxHelpers.ts new file mode 100644 index 00000000..14eb7c2b --- /dev/null +++ b/KeychainExample/e2e/utils/detoxHelpers.ts @@ -0,0 +1,36 @@ +import { by, element, waitFor, expect } from 'detox'; + +async function retry( + operation: () => Promise, + maxAttempts: number = 3, + delayMs: number = 1000 +): Promise { + let attempts = 0; + + while (attempts < maxAttempts) { + try { + return await operation(); + } catch (error) { + attempts++; + if (attempts === maxAttempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + throw new Error('Unreachable code'); +} + +export async function expectRegexText(regex: RegExp, timeout?: number) { + try { + return await retry(async () => + timeout + ? waitFor(element(by.text(regex))) + .toBeVisible() + .withTimeout(timeout) + : expect(element(by.text(regex))).toBeVisible() + ); + } catch (error) { + throw new Error(`Failed to find text matching ${regex}: ${error}`); + } +} diff --git a/KeychainExample/e2e/utils/matchLoadInfo.ts b/KeychainExample/e2e/utils/matchLoadInfo.ts deleted file mode 100644 index dbbd42a7..00000000 --- a/KeychainExample/e2e/utils/matchLoadInfo.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { by, element, waitFor } from 'detox'; - -export const matchLoadInfo = async ( - username: string, - password: string, - storage?: string, - service?: string -) => { - let regexPattern; - - if (!storage) { - regexPattern = `^Credentials loaded! .*"password":"${password}","username":"${username}"`; - } else { - regexPattern = `^Credentials loaded! .*"storage":"${storage}","password":"${password}","username":"${username}"`; - } - - if (service) { - regexPattern += `,"service":"${service}"`; - } - - regexPattern += '.*$'; - const regex = new RegExp(regexPattern); - await waitFor(element(by.text(regex))) - .toExist() - .withTimeout(3000); -}; diff --git a/KeychainExample/e2e/utils/statusMessageHelpers.ts b/KeychainExample/e2e/utils/statusMessageHelpers.ts new file mode 100644 index 00000000..8832e8d5 --- /dev/null +++ b/KeychainExample/e2e/utils/statusMessageHelpers.ts @@ -0,0 +1,49 @@ +import { expectRegexText } from './detoxHelpers'; + +const TIMEOUT = 10000; + +function buildLoadedCredentialsRegex( + username: string, + password: string, + storage?: string, + service?: string +): RegExp { + let pattern = '^Credentials loaded! .*'; + // Conditionally add storage if provided. + if (storage) { + pattern += `"storage":"${storage}",`; + } + // Always add password and username. + pattern += `"password":"${password}","username":"${username}"`; + // Conditionally add service if provided. + if (service) { + pattern += `,"service":"${service}"`; + } + pattern += '.*$'; + return new RegExp(pattern); +} + +export async function expectCredentialsSavedMessage() { + const regex = /^Credentials saved! .*$/; + await expectRegexText(regex, TIMEOUT); +} + +export async function expectCredentialsResetMessage() { + const regex = /^Credentials Reset!$/; + await expectRegexText(regex, TIMEOUT); +} + +export async function expectCredentialsLoadedMessage( + username: string, + password: string, + storage?: string, + service?: string +) { + const regex = buildLoadedCredentialsRegex( + username, + password, + storage, + service + ); + await expectRegexText(regex, TIMEOUT); +} diff --git a/KeychainExample/src/App.tsx b/KeychainExample/src/App.tsx index 95210951..1cf43788 100644 --- a/KeychainExample/src/App.tsx +++ b/KeychainExample/src/App.tsx @@ -264,7 +264,11 @@ export default function App() { /> )} - {!!status && {status}} + {!!status && ( + + {status} + + )} diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt index adc9c007..00453a74 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt @@ -14,6 +14,7 @@ import com.oblador.keychain.cipherStorage.CipherStorageBase.DecryptBytesHandler import com.oblador.keychain.cipherStorage.CipherStorageBase.EncryptStringHandler import com.oblador.keychain.exceptions.CryptoFailedException import com.oblador.keychain.exceptions.KeyStoreAccessException +import com.oblador.keychain.resultHandler.ResultHandler import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.IOException @@ -38,11 +39,21 @@ import javax.crypto.NoSuchPaddingException abstract class CipherStorageBase(protected val applicationContext: Context) : CipherStorage { // region Constants /** Logging tag. */ - protected val LOG_TAG = CipherStorageBase::class.java.simpleName + protected val LOG_TAG get() = Companion.LOG_TAG /** Default key storage type/name. */ companion object { const val KEYSTORE_TYPE = "AndroidKeyStore" + const val PREFIX_DELIMITER = "_" + + /** Logging tag. */ + private val LOG_TAG = CipherStorageBase::class.java.simpleName + + // Prefix constants + const val PREFIX_RSA = "RSA" + const val PREFIX_AES_GCM = "AES_GCM" + const val PREFIX_AES_GCM_NO_AUTH = "AES_GCM_NA" + const val PREFIX_AES_CBC = "AES_CBC" /** Size of hash calculation buffer. Default: 4Kb. */ private const val BUFFER_SIZE = 4 * 1024 @@ -73,6 +84,44 @@ abstract class CipherStorageBase(protected val applicationContext: Context) : Ci output.write(buf, 0, len) } } + + fun getPrefixedAlias(alias: String, prefix: String): String { + return if (alias.startsWith("$prefix$PREFIX_DELIMITER")) { + alias + } else { + "$prefix$PREFIX_DELIMITER$alias" + } + } + + + fun migrateLegacyKey( + legacyAlias: String, + newAlias: String, + keyStore: KeyStore, + handler: ResultHandler, + level: SecurityLevel + ) { + try { + val legacyKey = keyStore.getKey(legacyAlias, null) ?: return + + // Save the key under new alias + keyStore.setKeyEntry( + newAlias, + legacyKey, + null, + keyStore.getCertificateChain(legacyAlias) + ) + + // Verify the new key works + if (keyStore.getKey(newAlias, null) != null) { + // If successful, delete the old key + keyStore.deleteEntry(legacyAlias) + Log.d(LOG_TAG, "Successfully migrated key from $legacyAlias to $newAlias") + } + } catch (e: Exception) { + Log.w(LOG_TAG, "Failed to migrate legacy key $legacyAlias: ${e.message}") + } + } } // endregion @@ -108,12 +157,20 @@ abstract class CipherStorageBase(protected val applicationContext: Context) : Ci /** Remove key with provided name from security storage. */ override fun removeKey(alias: String) { - val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val defaultAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + // Try removing both prefixed and unprefixed versions for migration support val ks = getKeyStoreAndLoad() try { - if (ks.containsAlias(safeAlias)) { - ks.deleteEntry(safeAlias) + if (ks.containsAlias(defaultAlias)) { + ks.deleteEntry(defaultAlias) + } + // Try each possible prefix + listOf(PREFIX_RSA, PREFIX_AES_GCM, PREFIX_AES_GCM_NO_AUTH, PREFIX_AES_CBC).forEach { prefix -> + val prefixedAlias = getPrefixedAlias(defaultAlias, prefix) + if (ks.containsAlias(prefixedAlias)) { + ks.deleteEntry(prefixedAlias) + } } } catch (ignored: GeneralSecurityException) { /* only one exception can be raised by code: 'KeyStore is not loaded' */ @@ -124,7 +181,15 @@ abstract class CipherStorageBase(protected val applicationContext: Context) : Ci val ks = getKeyStoreAndLoad() try { val aliases = ks.aliases() - return HashSet(Collections.list(aliases)) + // Strip prefixes when returning keys + return HashSet(Collections.list(aliases).map { alias -> + listOf(PREFIX_RSA, PREFIX_AES_GCM, PREFIX_AES_GCM_NO_AUTH, PREFIX_AES_CBC).forEach { prefix -> + if (alias.startsWith("$prefix$PREFIX_DELIMITER")) { + return@map alias.substring(prefix.length + PREFIX_DELIMITER.length) + } + } + alias + }) } catch (e: KeyStoreException) { throw KeyStoreAccessException("Error accessing aliases in keystore $ks", e) } @@ -242,6 +307,42 @@ abstract class CipherStorageBase(protected val applicationContext: Context) : Ci return key } + /** + * Try to extract key, first with prefix then fallback to legacy format + */ + @Throws(GeneralSecurityException::class) + protected fun extractKeyWithMigration( + alias: String, + prefix: String, + handler: ResultHandler, + level: SecurityLevel, + retries: AtomicInteger + ): Key { + val prefixedAlias = getPrefixedAlias(alias, prefix) + val keyStore = getKeyStoreAndLoad() + + // First try prefixed alias + if (keyStore.containsAlias(prefixedAlias)) { + return extractKey(keyStore, prefixedAlias, retries) ?: + throw KeyStoreAccessException("Failed to extract key for $prefixedAlias") + } + + // Try legacy alias + if (keyStore.containsAlias(alias)) { + val key = extractKey(keyStore, alias, retries) + if (key != null) { + // Migrate to new format + migrateLegacyKey(alias, prefixedAlias, keyStore, handler, level) + return key + } + } + + // No existing key found, create new one with prefix + generateKeyAndStoreUnderAlias(prefixedAlias, level) + return extractKey(keyStore, prefixedAlias, retries) ?: + throw KeyStoreAccessException("Failed to generate key for $prefixedAlias") + } + /** Verify that provided key satisfy minimal needed level. */ @Throws(GeneralSecurityException::class) protected fun validateKeySecurityLevel(level: SecurityLevel, key: Key): Boolean { diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt index 98c399fd..1761cacc 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt @@ -85,14 +85,16 @@ class CipherStorageKeystoreAesCbc(reactContext: ReactApplicationContext) : throwIfInsufficientLevel(level) - val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val defaultAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val safeAlias = getPrefixedAlias(defaultAlias, PREFIX_AES_CBC) val retries = AtomicInteger(1) try { val key = extractGeneratedKey(safeAlias, level, retries) - val result = CipherStorage.EncryptionResult( - encryptString(key, username), encryptString(key, password), this + encryptString(key, username), + encryptString(key, password), + this ) handler.onEncrypt(result, null) } catch (e: GeneralSecurityException) { @@ -118,18 +120,17 @@ class CipherStorageKeystoreAesCbc(reactContext: ReactApplicationContext) : throwIfInsufficientLevel(level) - val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val defaultAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) val retries = AtomicInteger(1) try { - val key = extractGeneratedKey(safeAlias, level, retries) - + val key = extractKeyWithMigration(defaultAlias, PREFIX_AES_CBC, handler, level, retries) val results = CipherStorage.DecryptionResult( - decryptBytes(key, username), decryptBytes(key, password), getSecurityLevel(key) + decryptBytes(key, username), + decryptBytes(key, password), + getSecurityLevel(key) ) handler.onDecrypt(results, null) - } catch (e: GeneralSecurityException) { - throw CryptoFailedException("Could not decrypt data with alias: $alias", e) } catch (fail: Throwable) { handler.onDecrypt(null, fail) } @@ -145,9 +146,10 @@ class CipherStorageKeystoreAesCbc(reactContext: ReactApplicationContext) : override fun getKeyGenSpecBuilder( alias: String ): KeyGenParameterSpec.Builder { + val safeAlias = getPrefixedAlias(alias, PREFIX_AES_CBC) val purposes = KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT - return KeyGenParameterSpec.Builder(alias, purposes) + return KeyGenParameterSpec.Builder(safeAlias, purposes) .setBlockModes(BLOCK_MODE_CBC) .setEncryptionPaddings(PADDING_PKCS7) .setRandomizedEncryptionRequired(true) @@ -172,7 +174,6 @@ class CipherStorageKeystoreAesCbc(reactContext: ReactApplicationContext) : // initialize key generator generator.init(spec) - return generator.generateKey() } diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt index 6b6e6261..9b281dc0 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt @@ -25,7 +25,8 @@ import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec class CipherStorageKeystoreAesGcm( - reactContext: ReactApplicationContext, private val requiresAuth: Boolean + reactContext: ReactApplicationContext, + private val requiresAuth: Boolean ) : CipherStorageBase(reactContext) { // region Constants @@ -84,7 +85,8 @@ class CipherStorageKeystoreAesGcm( throwIfInsufficientLevel(level) - val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val defaultAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val safeAlias = getPrefixedAuthAlias(defaultAlias) val retries = AtomicInteger(1) var key: Key? = null @@ -122,26 +124,24 @@ class CipherStorageKeystoreAesGcm( ) { throwIfInsufficientLevel(level) - val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val defaultAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val prefix = if (requiresAuth) PREFIX_AES_GCM else PREFIX_AES_GCM_NO_AUTH val retries = AtomicInteger(1) var key: Key? = null try { - key = extractGeneratedKey(safeAlias, level, retries) + key = extractKeyWithMigration(defaultAlias, prefix, handler, level, retries) val results = CipherStorage.DecryptionResult( - decryptBytes(key, username), decryptBytes(key, password) + decryptBytes(key, username), + decryptBytes(key, password) ) - handler.onDecrypt(results, null) } catch (ex: UserNotAuthenticatedException) { Log.d(LOG_TAG, "Unlock of keystore is needed. Error: ${ex.message}", ex) - // expected that KEY instance is extracted and we caught exception on decryptBytes operation - val context = - CryptoContext(safeAlias, key!!, password, username, CryptoOperation.DECRYPT) - + val context = CryptoContext(getPrefixedAlias(defaultAlias, prefix), + key!!, password, username, CryptoOperation.DECRYPT) handler.askAccessPermissions(context) } catch (fail: Throwable) { - // any other exception treated as a failure handler.onDecrypt(null, fail) } } @@ -159,8 +159,9 @@ class CipherStorageKeystoreAesGcm( val purposes = KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT val validityDuration = 5 + val safeAlias = getPrefixedAuthAlias(alias) val keyGenParameterSpecBuilder = - KeyGenParameterSpec.Builder(alias, purposes).setBlockModes(BLOCK_MODE_GCM) + KeyGenParameterSpec.Builder(safeAlias, purposes).setBlockModes(BLOCK_MODE_GCM) .setEncryptionPaddings(PADDING_NONE).setRandomizedEncryptionRequired(true) .setKeySize(ENCRYPTION_KEY_SIZE) @@ -199,7 +200,6 @@ class CipherStorageKeystoreAesGcm( // initialize key generator generator.init(spec) - return generator.generateKey() } @@ -243,4 +243,14 @@ class CipherStorageKeystoreAesGcm( decryptBytes(key, bytes, IV.decrypt) // endregion + + // region Alias Helpers + + private fun getPrefixedAuthAlias(alias: String): String { + val prefix = if (requiresAuth) PREFIX_AES_GCM else PREFIX_AES_GCM_NO_AUTH + return getPrefixedAlias(alias, prefix) + } + + // endregion + } diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt index fc8ebf18..c797984b 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt @@ -59,7 +59,8 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : ) { throwIfInsufficientLevel(level) - val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val defaultAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val safeAlias = getPrefixedAlias(defaultAlias, PREFIX_RSA) val retries = AtomicInteger(1) try { extractGeneratedKey(safeAlias, level, retries) @@ -106,23 +107,18 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : var key: Key? = null try { - // key is always NOT NULL otherwise GeneralSecurityException raised - key = extractGeneratedKey(safeAlias, level, retries) - - val results = - CipherStorage.DecryptionResult( - decryptBytes(key, username), - decryptBytes(key, password) - ) - + // key is always NOT NULL otherwise GeneralSecurityException is thrown + key = extractKeyWithMigration(safeAlias, PREFIX_RSA, handler, level, retries) + val results = CipherStorage.DecryptionResult( + decryptBytes(key, username), + decryptBytes(key, password) + ) handler.onDecrypt(results, null) } catch (ex: UserNotAuthenticatedException) { Log.d(LOG_TAG, "Unlock of keystore is needed. Error: ${ex.message}", ex) - - // expected that KEY instance is extracted and we caught exception on decryptBytes operation - val context = - CryptoContext(safeAlias, key!!, password, username, CryptoOperation.DECRYPT) - + // expected that KEY instance is extracted and we caught expection on decrtptBytes operation + val context = CryptoContext(getPrefixedAlias(safeAlias, PREFIX_RSA), + key!!, password, username, CryptoOperation.DECRYPT) handler.askAccessPermissions(context) } catch (fail: Throwable) { // any other exception treated as a failure @@ -157,9 +153,10 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : ): CipherStorage.EncryptionResult { val keyStore = getKeyStoreAndLoad() - // Retrieve the certificate after ensuring the key is compatible - val certificate = keyStore.getCertificate(alias) - ?: throw GeneralSecurityException("Certificate is null for alias $alias") + // Retrieve the certificate after ensuring the key is compatible + val prefixedAlias = getPrefixedAlias(alias, PREFIX_RSA) + val certificate = keyStore.getCertificate(prefixedAlias) + ?: throw GeneralSecurityException("Certificate is null for alias $prefixedAlias") val publicKey = certificate.publicKey val kf = KeyFactory.getInstance(ALGORITHM_RSA) @@ -177,6 +174,7 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : override fun getKeyGenSpecBuilder( alias: String, ): KeyGenParameterSpec.Builder { + val safeAlias = getPrefixedAlias(alias, PREFIX_RSA) val purposes = KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT @@ -184,7 +182,7 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : val validityDuration = 5 val keyGenParameterSpecBuilder = - KeyGenParameterSpec.Builder(alias, purposes) + KeyGenParameterSpec.Builder(safeAlias, purposes) .setBlockModes(BLOCK_MODE_ECB) .setEncryptionPaddings(PADDING_PKCS1) .setRandomizedEncryptionRequired(true) @@ -215,10 +213,8 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : /** Try to generate key from provided specification. */ @Throws(GeneralSecurityException::class) override fun generateKey(spec: KeyGenParameterSpec): Key { - val generator = KeyPairGenerator.getInstance(getEncryptionAlgorithm(), KEYSTORE_TYPE) generator.initialize(spec) - return generator.generateKeyPair().private } }