Minimal security guarantees for react-native-keychain (#6)
* Implement security level guarantees for Android. Supported security levels: - ANY - SECURE_SOFTWARE - SECURE_HARDWARE (TEE or SE guarantees). (1) Add `getSecurityLevel()` API that returns which security level is supported on this Android version and the specific device. (2) For APIs that store credentials, an additional optional parameter was added that fails storing the credentials if the security level is not what is expected. ``` // Store the credentials. // Will fail if Keychain can't guarantee at least SECURE_HARDWARE level of encryption key. await Keychain.setGenericPassword(username, password, Keychain.SECURITY_LEVEL.SECURE_HARDWARE); ``` (3) StongBox support on Android 9+ (and supported devices [Pixel 3]). Co-Authored-By: mandrigin <mandrigin@users.noreply.github.com>
This commit is contained in:
parent
ea1bfe6b80
commit
43e5512cab
16
README.md
16
README.md
|
@ -45,7 +45,7 @@ See `KeychainExample` for fully working project example.
|
||||||
|
|
||||||
Both `setGenericPassword` and `setInternetCredentials` are limited to strings only, so if you need to store objects etc, please use `JSON.stringify`/`JSON.parse` when you store/access it.
|
Both `setGenericPassword` and `setInternetCredentials` are limited to strings only, so if you need to store objects etc, please use `JSON.stringify`/`JSON.parse` when you store/access it.
|
||||||
|
|
||||||
### `setGenericPassword(username, password, [{ accessControl, accessible, accessGroup, service }])`
|
### `setGenericPassword(username, password, securityLevel, [{ accessControl, accessible, accessGroup, service }])`
|
||||||
|
|
||||||
Will store the username/password combination in the secure storage. Resolves to `true` or rejects in case of an error.
|
Will store the username/password combination in the secure storage. Resolves to `true` or rejects in case of an error.
|
||||||
|
|
||||||
|
@ -57,7 +57,7 @@ Will retreive the username/password combination from the secure storage. Resolve
|
||||||
|
|
||||||
Will remove the username/password combination from the secure storage.
|
Will remove the username/password combination from the secure storage.
|
||||||
|
|
||||||
### `setInternetCredentials(server, username, password, [{ accessControl, accessible, accessGroup }])`
|
### `setInternetCredentials(server, username, password, securityLevel, [{ accessControl, accessible, accessGroup }])`
|
||||||
|
|
||||||
Will store the server/username/password combination in the secure storage.
|
Will store the server/username/password combination in the secure storage.
|
||||||
|
|
||||||
|
@ -85,6 +85,18 @@ Inquire if the type of local authentication policy is supported on this device w
|
||||||
|
|
||||||
Get what type of hardware biometry support the device has. Resolves to a `Keychain.BIOMETRY_TYPE` value when supported, otherwise `null`.
|
Get what type of hardware biometry support the device has. Resolves to a `Keychain.BIOMETRY_TYPE` value when supported, otherwise `null`.
|
||||||
|
|
||||||
|
### `getSecurityLevel()`
|
||||||
|
|
||||||
|
Get security level that is supported on the current device with the current OS.
|
||||||
|
|
||||||
|
### Security Levels (Android only)
|
||||||
|
|
||||||
|
If set, `securityLevel` parameter specifies minimum security level that the encryption key storage should guarantee for storing credentials to succeed.
|
||||||
|
|
||||||
|
* `ANY` - no security guarantees needed (default value); Credentials can be stored in FB Secure Storage;
|
||||||
|
* `SECURE_SOFTWARE` - requires for the key to be stored in the Android Keystore, separate from the encrypted data;
|
||||||
|
* `SECURE_HARDWARE` - requires for the key to be stored on a secure hardware (Trusted Execution Environment or Secure Environment). Read [this article](https://developer.android.com/training/articles/keystore#ExtractionPrevention) for more information.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
| Key | Platform | Description | Default |
|
| Key | Platform | Description | Default |
|
||||||
|
|
|
@ -285,7 +285,7 @@ RCT_EXPORT_METHOD(getSupportedBiometryType:(RCTPromiseResolveBlock)resolve rejec
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
RCT_EXPORT_METHOD(setGenericPasswordForOptions:(NSDictionary *)options withUsername:(NSString *)username withPassword:(NSString *)password resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
RCT_EXPORT_METHOD(setGenericPasswordForOptions:(NSDictionary *)options withUsername:(NSString *)username withPassword:(NSString *)password withSecurityLevel:(__unused NSString *)level resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
||||||
{
|
{
|
||||||
NSString *service = serviceValue(options);
|
NSString *service = serviceValue(options);
|
||||||
NSDictionary *attributes = attributes = @{
|
NSDictionary *attributes = attributes = @{
|
||||||
|
@ -358,7 +358,7 @@ RCT_EXPORT_METHOD(resetGenericPasswordForOptions:(NSDictionary *)options resolve
|
||||||
return resolve(@(YES));
|
return resolve(@(YES));
|
||||||
}
|
}
|
||||||
|
|
||||||
RCT_EXPORT_METHOD(setInternetCredentialsForServer:(NSString *)server withUsername:(NSString*)username withPassword:(NSString*)password withOptions:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
RCT_EXPORT_METHOD(setInternetCredentialsForServer:(NSString *)server withUsername:(NSString*)username withPassword:(NSString*)password withSecurityLevel:(__unused NSString *)level withOptions:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
||||||
{
|
{
|
||||||
[self deleteCredentialsForServer:server];
|
[self deleteCredentialsForServer:server];
|
||||||
|
|
||||||
|
|
|
@ -10,13 +10,17 @@ buildscript {
|
||||||
|
|
||||||
apply plugin: 'com.android.library'
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
def safeExtGet(prop, fallback) {
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 23
|
compileSdkVersion safeExtGet('compileSdkVersion', 28)
|
||||||
buildToolsVersion "23.0.1"
|
buildToolsVersion safeExtGet('buildToolsVersion', '26.0.3')
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 16
|
minSdkVersion safeExtGet('minSdkVersion', 16)
|
||||||
targetSdkVersion 23
|
targetSdkVersion safeExtGet('targetSdkVersion', 26)
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,6 @@ import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAESCBC;
|
||||||
import com.oblador.keychain.exceptions.CryptoFailedException;
|
import com.oblador.keychain.exceptions.CryptoFailedException;
|
||||||
import com.oblador.keychain.exceptions.EmptyParameterException;
|
import com.oblador.keychain.exceptions.EmptyParameterException;
|
||||||
import com.oblador.keychain.exceptions.KeyStoreAccessException;
|
import com.oblador.keychain.exceptions.KeyStoreAccessException;
|
||||||
import com.oblador.keychain.DeviceAvailability;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -56,16 +55,23 @@ public class KeychainModule extends ReactContextBaseJavaModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
public void setGenericPasswordForOptions(String service, String username, String password, Promise promise) {
|
public void getSecurityLevel(Promise promise) {
|
||||||
|
promise.resolve(getSecurityLevel().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
public void setGenericPasswordForOptions(String service, String username, String password, String minimumSecurityLevel, Promise promise) {
|
||||||
try {
|
try {
|
||||||
|
SecurityLevel level = SecurityLevel.valueOf(minimumSecurityLevel);
|
||||||
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
|
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
|
||||||
throw new EmptyParameterException("you passed empty or null username/password");
|
throw new EmptyParameterException("you passed empty or null username/password");
|
||||||
}
|
}
|
||||||
service = getDefaultServiceIfNull(service);
|
service = getDefaultServiceIfNull(service);
|
||||||
|
|
||||||
CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel();
|
CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel();
|
||||||
|
validateCipherStorageSecurityLevel(currentCipherStorage, level);
|
||||||
|
|
||||||
EncryptionResult result = currentCipherStorage.encrypt(service, username, password);
|
EncryptionResult result = currentCipherStorage.encrypt(service, username, password, level);
|
||||||
prefsStorage.storeEncryptedEntry(service, result);
|
prefsStorage.storeEncryptedEntry(service, result);
|
||||||
|
|
||||||
promise.resolve(true);
|
promise.resolve(true);
|
||||||
|
@ -103,11 +109,17 @@ public class KeychainModule extends ReactContextBaseJavaModule {
|
||||||
// decrypt using the older cipher storage
|
// decrypt using the older cipher storage
|
||||||
decryptionResult = oldCipherStorage.decrypt(service, resultSet.usernameBytes, resultSet.passwordBytes);
|
decryptionResult = oldCipherStorage.decrypt(service, resultSet.usernameBytes, resultSet.passwordBytes);
|
||||||
// encrypt using the current cipher storage
|
// encrypt using the current cipher storage
|
||||||
EncryptionResult encryptionResult = currentCipherStorage.encrypt(service, decryptionResult.username, decryptionResult.password);
|
|
||||||
|
try {
|
||||||
|
// don't allow to degrade security level when transferring, the new storage should be as safe as the old one.
|
||||||
|
EncryptionResult encryptionResult = currentCipherStorage.encrypt(service, decryptionResult.username, decryptionResult.password, decryptionResult.getSecurityLevel());
|
||||||
// store the encryption result
|
// store the encryption result
|
||||||
prefsStorage.storeEncryptedEntry(service, encryptionResult);
|
prefsStorage.storeEncryptedEntry(service, encryptionResult);
|
||||||
// clean up the old cipher storage
|
// clean up the old cipher storage
|
||||||
oldCipherStorage.removeKey(service);
|
oldCipherStorage.removeKey(service);
|
||||||
|
} catch (CryptoFailedException e) {
|
||||||
|
Log.e(KEYCHAIN_MODULE, "Migrating to a less safe storage is not allowed. Keeping the old one");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WritableMap credentials = Arguments.createMap();
|
WritableMap credentials = Arguments.createMap();
|
||||||
|
@ -150,8 +162,8 @@ public class KeychainModule extends ReactContextBaseJavaModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
public void setInternetCredentialsForServer(@NonNull String server, String username, String password, ReadableMap unusedOptions, Promise promise) {
|
public void setInternetCredentialsForServer(@NonNull String server, String username, String password, String minimumSecurityLevel, ReadableMap unusedOptions, Promise promise) {
|
||||||
setGenericPasswordForOptions(server, username, password, promise);
|
setGenericPasswordForOptions(server, username, password, minimumSecurityLevel, promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
|
@ -187,8 +199,11 @@ public class KeychainModule extends ReactContextBaseJavaModule {
|
||||||
int cipherStorageAPILevel = cipherStorage.getMinSupportedApiLevel();
|
int cipherStorageAPILevel = cipherStorage.getMinSupportedApiLevel();
|
||||||
// Is the cipherStorage supported on the current API level?
|
// Is the cipherStorage supported on the current API level?
|
||||||
boolean isSupported = (cipherStorageAPILevel <= currentAPILevel);
|
boolean isSupported = (cipherStorageAPILevel <= currentAPILevel);
|
||||||
|
if (!isSupported) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Is the API level better than the one we previously selected (if any)?
|
// Is the API level better than the one we previously selected (if any)?
|
||||||
if (isSupported && (currentCipherStorage == null || cipherStorageAPILevel > currentCipherStorage.getMinSupportedApiLevel())) {
|
if (currentCipherStorage == null || cipherStorageAPILevel > currentCipherStorage.getMinSupportedApiLevel()) {
|
||||||
currentCipherStorage = cipherStorage;
|
currentCipherStorage = cipherStorage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -198,6 +213,19 @@ public class KeychainModule extends ReactContextBaseJavaModule {
|
||||||
return currentCipherStorage;
|
return currentCipherStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateCipherStorageSecurityLevel(CipherStorage cipherStorage, SecurityLevel requiredLevel) throws CryptoFailedException {
|
||||||
|
if (cipherStorage.securityLevel().satisfiesSafetyThreshold(requiredLevel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CryptoFailedException(
|
||||||
|
String.format(
|
||||||
|
"Cipher Storage is too weak. Required security level is: %s, but only %s is provided",
|
||||||
|
requiredLevel.name(),
|
||||||
|
cipherStorage.securityLevel().name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private CipherStorage getCipherStorageByName(String cipherStorageName) {
|
private CipherStorage getCipherStorageByName(String cipherStorageName) {
|
||||||
return cipherStorageMap.get(cipherStorageName);
|
return cipherStorageMap.get(cipherStorageName);
|
||||||
}
|
}
|
||||||
|
@ -206,6 +234,33 @@ public class KeychainModule extends ReactContextBaseJavaModule {
|
||||||
return DeviceAvailability.isFingerprintAuthAvailable(getCurrentActivity());
|
return DeviceAvailability.isFingerprintAuthAvailable(getCurrentActivity());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSecureHardwareAvailable() {
|
||||||
|
try {
|
||||||
|
return getCipherStorageForCurrentAPILevel().supportsSecureHardware();
|
||||||
|
} catch (CryptoFailedException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SecurityLevel getSecurityLevel() {
|
||||||
|
try {
|
||||||
|
CipherStorage storage = getCipherStorageForCurrentAPILevel();
|
||||||
|
if (!storage.securityLevel().satisfiesSafetyThreshold(SecurityLevel.SECURE_SOFTWARE)) {
|
||||||
|
return SecurityLevel.ANY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSecureHardwareAvailable()) {
|
||||||
|
return SecurityLevel.SECURE_HARDWARE;
|
||||||
|
} else {
|
||||||
|
return SecurityLevel.SECURE_SOFTWARE;
|
||||||
|
}
|
||||||
|
} catch (CryptoFailedException e) {
|
||||||
|
return SecurityLevel.ANY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
private String getDefaultServiceIfNull(String service) {
|
private String getDefaultServiceIfNull(String service) {
|
||||||
return service == null ? EMPTY_STRING : service;
|
return service == null ? EMPTY_STRING : service;
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.oblador.keychain;
|
||||||
|
|
||||||
|
public enum SecurityLevel {
|
||||||
|
ANY,
|
||||||
|
SECURE_SOFTWARE,
|
||||||
|
SECURE_HARDWARE; // Trusted Execution Environment or Secure Environment guarantees
|
||||||
|
|
||||||
|
public boolean satisfiesSafetyThreshold(SecurityLevel threshold) {
|
||||||
|
return this.compareTo(threshold) >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.oblador.keychain.cipherStorage;
|
||||||
|
|
||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.oblador.keychain.SecurityLevel;
|
||||||
import com.oblador.keychain.exceptions.CryptoFailedException;
|
import com.oblador.keychain.exceptions.CryptoFailedException;
|
||||||
import com.oblador.keychain.exceptions.KeyStoreAccessException;
|
import com.oblador.keychain.exceptions.KeyStoreAccessException;
|
||||||
|
|
||||||
|
@ -26,12 +27,19 @@ public interface CipherStorage {
|
||||||
}
|
}
|
||||||
|
|
||||||
class DecryptionResult extends CipherResult<String> {
|
class DecryptionResult extends CipherResult<String> {
|
||||||
public DecryptionResult(String username, String password) {
|
private SecurityLevel securityLevel;
|
||||||
|
|
||||||
|
public DecryptionResult(String username, String password, SecurityLevel level) {
|
||||||
super(username, password);
|
super(username, password);
|
||||||
|
securityLevel = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SecurityLevel getSecurityLevel() {
|
||||||
|
return securityLevel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password) throws CryptoFailedException;
|
EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password, SecurityLevel level) throws CryptoFailedException;
|
||||||
|
|
||||||
DecryptionResult decrypt(@NonNull String service, @NonNull byte[] username, @NonNull byte[] password) throws CryptoFailedException;
|
DecryptionResult decrypt(@NonNull String service, @NonNull byte[] username, @NonNull byte[] password) throws CryptoFailedException;
|
||||||
|
|
||||||
|
@ -40,4 +48,8 @@ public interface CipherStorage {
|
||||||
String getCipherStorageName();
|
String getCipherStorageName();
|
||||||
|
|
||||||
int getMinSupportedApiLevel();
|
int getMinSupportedApiLevel();
|
||||||
|
|
||||||
|
SecurityLevel securityLevel();
|
||||||
|
|
||||||
|
boolean supportsSecureHardware();
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import com.facebook.crypto.CryptoConfig;
|
||||||
import com.facebook.crypto.Entity;
|
import com.facebook.crypto.Entity;
|
||||||
import com.facebook.crypto.keychain.KeyChain;
|
import com.facebook.crypto.keychain.KeyChain;
|
||||||
import com.facebook.react.bridge.ReactApplicationContext;
|
import com.facebook.react.bridge.ReactApplicationContext;
|
||||||
|
import com.oblador.keychain.SecurityLevel;
|
||||||
import com.oblador.keychain.exceptions.CryptoFailedException;
|
import com.oblador.keychain.exceptions.CryptoFailedException;
|
||||||
|
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
|
@ -35,7 +36,22 @@ public class CipherStorageFacebookConceal implements CipherStorage {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password) throws CryptoFailedException {
|
public SecurityLevel securityLevel() {
|
||||||
|
return SecurityLevel.ANY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsSecureHardware() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password, SecurityLevel level) throws CryptoFailedException {
|
||||||
|
|
||||||
|
if (!this.securityLevel().satisfiesSafetyThreshold(level)) {
|
||||||
|
throw new CryptoFailedException(String.format("Insufficient security level (wants %s; got %s)", level, this.securityLevel()));
|
||||||
|
}
|
||||||
|
|
||||||
if (!crypto.isAvailable()) {
|
if (!crypto.isAvailable()) {
|
||||||
throw new CryptoFailedException("Crypto is missing");
|
throw new CryptoFailedException("Crypto is missing");
|
||||||
}
|
}
|
||||||
|
@ -66,7 +82,8 @@ public class CipherStorageFacebookConceal implements CipherStorage {
|
||||||
|
|
||||||
return new DecryptionResult(
|
return new DecryptionResult(
|
||||||
new String(decryptedUsername, Charset.forName("UTF-8")),
|
new String(decryptedUsername, Charset.forName("UTF-8")),
|
||||||
new String(decryptedPassword, Charset.forName("UTF-8")));
|
new String(decryptedPassword, Charset.forName("UTF-8")),
|
||||||
|
SecurityLevel.ANY);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new CryptoFailedException("Decryption failed for service " + service, e);
|
throw new CryptoFailedException("Decryption failed for service " + service, e);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,12 @@ package com.oblador.keychain.cipherStorage;
|
||||||
import android.annotation.TargetApi;
|
import android.annotation.TargetApi;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.security.keystore.KeyGenParameterSpec;
|
import android.security.keystore.KeyGenParameterSpec;
|
||||||
|
import android.security.keystore.KeyInfo;
|
||||||
import android.security.keystore.KeyProperties;
|
import android.security.keystore.KeyProperties;
|
||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.oblador.keychain.SecurityLevel;
|
||||||
import com.oblador.keychain.exceptions.CryptoFailedException;
|
import com.oblador.keychain.exceptions.CryptoFailedException;
|
||||||
import com.oblador.keychain.exceptions.KeyStoreAccessException;
|
import com.oblador.keychain.exceptions.KeyStoreAccessException;
|
||||||
|
|
||||||
|
@ -21,15 +24,19 @@ import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.NoSuchProviderException;
|
import java.security.NoSuchProviderException;
|
||||||
import java.security.UnrecoverableKeyException;
|
import java.security.UnrecoverableKeyException;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
import java.security.spec.AlgorithmParameterSpec;
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import android.security.keystore.StrongBoxUnavailableException;
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.CipherInputStream;
|
import javax.crypto.CipherInputStream;
|
||||||
import javax.crypto.CipherOutputStream;
|
import javax.crypto.CipherOutputStream;
|
||||||
import javax.crypto.KeyGenerator;
|
import javax.crypto.KeyGenerator;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.SecretKeyFactory;
|
||||||
import javax.crypto.spec.IvParameterSpec;
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
|
||||||
public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
||||||
|
public static final String TAG = "KeystoreAESCBC";
|
||||||
public static final String CIPHER_STORAGE_NAME = "KeystoreAESCBC";
|
public static final String CIPHER_STORAGE_NAME = "KeystoreAESCBC";
|
||||||
public static final String DEFAULT_SERVICE = "RN_KEYCHAIN_DEFAULT_ALIAS";
|
public static final String DEFAULT_SERVICE = "RN_KEYCHAIN_DEFAULT_ALIAS";
|
||||||
public static final String KEYSTORE_TYPE = "AndroidKeyStore";
|
public static final String KEYSTORE_TYPE = "AndroidKeyStore";
|
||||||
|
@ -52,30 +59,51 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
||||||
return Build.VERSION_CODES.M;
|
return Build.VERSION_CODES.M;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SecurityLevel securityLevel() {
|
||||||
|
// it can guarantee security levels up to SECURE_HARDWARE/SE/StrongBox
|
||||||
|
return SecurityLevel.SECURE_HARDWARE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsSecureHardware() {
|
||||||
|
final String testKeyAlias = "AndroidKeyStore#supportsSecureHardware";
|
||||||
|
|
||||||
|
try {
|
||||||
|
SecretKey key = tryGenerateRegularSecurityKey(testKeyAlias);
|
||||||
|
return validateKeySecurityLevel(SecurityLevel.SECURE_HARDWARE, key);
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchProviderException e) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
removeKey(testKeyAlias);
|
||||||
|
} catch (KeyStoreAccessException e) {
|
||||||
|
Log.e(TAG, "Unable to remove temp key from keychain", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.M)
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
@Override
|
@Override
|
||||||
public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password) throws CryptoFailedException {
|
public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password, SecurityLevel level) throws CryptoFailedException {
|
||||||
service = getDefaultServiceIfEmpty(service);
|
service = getDefaultServiceIfEmpty(service);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
KeyStore keyStore = getKeyStoreAndLoad();
|
KeyStore keyStore = getKeyStoreAndLoad();
|
||||||
|
|
||||||
if (!keyStore.containsAlias(service)) {
|
if (!keyStore.containsAlias(service)) {
|
||||||
AlgorithmParameterSpec spec;
|
// Firstly, try to generate the key as safe as possible (strongbox).
|
||||||
spec = new KeyGenParameterSpec.Builder(
|
// see https://developer.android.com/training/articles/keystore#HardwareSecurityModule
|
||||||
service,
|
SecretKey secretKey = tryGenerateStrongBoxSecurityKey(service);
|
||||||
KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT)
|
if (secretKey == null) {
|
||||||
.setBlockModes(ENCRYPTION_BLOCK_MODE)
|
// If that is not possible, we generate the key in a regular way
|
||||||
.setEncryptionPaddings(ENCRYPTION_PADDING)
|
// (it still might be generated in hardware, but not in StrongBox)
|
||||||
.setRandomizedEncryptionRequired(true)
|
secretKey = tryGenerateRegularSecurityKey(service);
|
||||||
//.setUserAuthenticationRequired(true) // Will throw InvalidAlgorithmParameterException if there is no fingerprint enrolled on the device
|
}
|
||||||
.setKeySize(ENCRYPTION_KEY_SIZE)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
KeyGenerator generator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, KEYSTORE_TYPE);
|
if (!validateKeySecurityLevel(level, secretKey)) {
|
||||||
generator.init(spec);
|
throw new CryptoFailedException("Cannot generate keys with required security guarantees");
|
||||||
|
}
|
||||||
generator.generateKey();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Key key = keyStore.getKey(service, null);
|
Key key = keyStore.getKey(service, null);
|
||||||
|
@ -88,6 +116,25 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
||||||
throw new CryptoFailedException("Could not encrypt data for service " + service, e);
|
throw new CryptoFailedException("Could not encrypt data for service " + service, e);
|
||||||
} catch (KeyStoreException | KeyStoreAccessException e) {
|
} catch (KeyStoreException | KeyStoreAccessException e) {
|
||||||
throw new CryptoFailedException("Could not access Keystore for service " + service, e);
|
throw new CryptoFailedException("Could not access Keystore for service " + service, e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoFailedException("Unknown error: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
|
private boolean validateKeySecurityLevel(SecurityLevel level, SecretKey generatedKey) {
|
||||||
|
return getSecurityLevel(generatedKey).satisfiesSafetyThreshold(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
|
private SecurityLevel getSecurityLevel(SecretKey key) {
|
||||||
|
try {
|
||||||
|
SecretKeyFactory factory = SecretKeyFactory.getInstance(key.getAlgorithm(), KEYSTORE_TYPE);
|
||||||
|
KeyInfo keyInfo;
|
||||||
|
keyInfo = (KeyInfo) factory.getKeySpec(key, KeyInfo.class);
|
||||||
|
return keyInfo.isInsideSecureHardware() ? SecurityLevel.SECURE_HARDWARE : SecurityLevel.SECURE_SOFTWARE;
|
||||||
|
} catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidKeySpecException e) {
|
||||||
|
return SecurityLevel.ANY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,11 +150,13 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
||||||
String decryptedUsername = decryptBytes(key, username);
|
String decryptedUsername = decryptBytes(key, username);
|
||||||
String decryptedPassword = decryptBytes(key, password);
|
String decryptedPassword = decryptBytes(key, password);
|
||||||
|
|
||||||
return new DecryptionResult(decryptedUsername, decryptedPassword);
|
return new DecryptionResult(decryptedUsername, decryptedPassword, getSecurityLevel((SecretKey) key));
|
||||||
} catch (KeyStoreException | UnrecoverableKeyException | NoSuchAlgorithmException e) {
|
} catch (KeyStoreException | UnrecoverableKeyException | NoSuchAlgorithmException e) {
|
||||||
throw new CryptoFailedException("Could not get key from Keystore", e);
|
throw new CryptoFailedException("Could not get key from Keystore", e);
|
||||||
} catch (KeyStoreAccessException e) {
|
} catch (KeyStoreAccessException e) {
|
||||||
throw new CryptoFailedException("Could not access Keystore", e);
|
throw new CryptoFailedException("Could not access Keystore", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new CryptoFailedException("Unknown error: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,6 +172,8 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
||||||
}
|
}
|
||||||
} catch (KeyStoreException e) {
|
} catch (KeyStoreException e) {
|
||||||
throw new KeyStoreAccessException("Failed to access Keystore", e);
|
throw new KeyStoreAccessException("Failed to access Keystore", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new KeyStoreAccessException("Unknown error " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,4 +240,46 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
|
||||||
private String getDefaultServiceIfEmpty(@NonNull String service) {
|
private String getDefaultServiceIfEmpty(@NonNull String service) {
|
||||||
return service.isEmpty() ? DEFAULT_SERVICE : service;
|
return service.isEmpty() ? DEFAULT_SERVICE : service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.P)
|
||||||
|
private SecretKey tryGenerateStrongBoxSecurityKey(String service) throws NoSuchAlgorithmException,
|
||||||
|
InvalidAlgorithmParameterException, NoSuchProviderException {
|
||||||
|
// StrongBox is only supported on Android P and higher
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return generateKey(getKeyGenSpecBuilder(service).setIsStrongBoxBacked(true).build());
|
||||||
|
} catch (StrongBoxUnavailableException e) {
|
||||||
|
Log.i(TAG, "StrongBox is unavailable on this device");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
|
private SecretKey tryGenerateRegularSecurityKey(String service) throws NoSuchAlgorithmException,
|
||||||
|
InvalidAlgorithmParameterException, NoSuchProviderException {
|
||||||
|
return generateKey(getKeyGenSpecBuilder(service).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns true if the key was generated successfully
|
||||||
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
|
private SecretKey generateKey(KeyGenParameterSpec spec) throws NoSuchProviderException,
|
||||||
|
NoSuchAlgorithmException, InvalidAlgorithmParameterException {
|
||||||
|
KeyGenerator generator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, KEYSTORE_TYPE);
|
||||||
|
generator.init(spec);
|
||||||
|
return generator.generateKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.M)
|
||||||
|
private KeyGenParameterSpec.Builder getKeyGenSpecBuilder(String service) {
|
||||||
|
return new KeyGenParameterSpec.Builder(
|
||||||
|
service,
|
||||||
|
KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT)
|
||||||
|
.setBlockModes(ENCRYPTION_BLOCK_MODE)
|
||||||
|
.setEncryptionPaddings(ENCRYPTION_PADDING)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
//.setUserAuthenticationRequired(true) // Will throw InvalidAlgorithmParameterException if there is no fingerprint enrolled on the device
|
||||||
|
.setKeySize(ENCRYPTION_KEY_SIZE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
54
index.js
54
index.js
|
@ -1,6 +1,12 @@
|
||||||
import { NativeModules, Platform } from 'react-native';
|
import { NativeModules, Platform } from 'react-native';
|
||||||
const { RNKeychainManager } = NativeModules;
|
const { RNKeychainManager } = NativeModules;
|
||||||
|
|
||||||
|
export const SECURITY_LEVEL = {
|
||||||
|
ANY: 'ANY',
|
||||||
|
SECURE_SOFTWARE: 'SECURE_SOFTWARE',
|
||||||
|
SECURE_HARDWARE: 'SECURE_HARDWARE',
|
||||||
|
};
|
||||||
|
|
||||||
export const ACCESSIBLE = {
|
export const ACCESSIBLE = {
|
||||||
WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
|
WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
|
||||||
AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
|
AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
|
||||||
|
@ -33,6 +39,11 @@ export const BIOMETRY_TYPE = {
|
||||||
FINGERPRINT: 'Fingerprint',
|
FINGERPRINT: 'Fingerprint',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SecMinimumLevel =
|
||||||
|
| 'ANY'
|
||||||
|
| 'SECURE_SOFTWARE'
|
||||||
|
| 'SECURE_HARDWARE' ;
|
||||||
|
|
||||||
type SecAccessible =
|
type SecAccessible =
|
||||||
| 'AccessibleWhenUnlocked'
|
| 'AccessibleWhenUnlocked'
|
||||||
| 'AccessibleAfterFirstUnlock'
|
| 'AccessibleAfterFirstUnlock'
|
||||||
|
@ -62,6 +73,18 @@ type Options = {
|
||||||
service?: string,
|
service?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Android only) Returns guaranteed security level supported by this library
|
||||||
|
* on the current device.
|
||||||
|
* @return {Promise} Resolves to `SECURITY_LEVEL` when supported, otherwise `null`.
|
||||||
|
*/
|
||||||
|
export function getSecurityLevel(): Promise {
|
||||||
|
if (!RNKeychainManager.getSecurityLevel){
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return RNKeychainManager.getSecurityLevel();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inquire if the type of local authentication policy (LAPolicy) is supported
|
* Inquire if the type of local authentication policy (LAPolicy) is supported
|
||||||
* on this device with the device settings the user chose.
|
* on this device with the device settings the user chose.
|
||||||
|
@ -91,6 +114,8 @@ export function getSupportedBiometryType(): Promise {
|
||||||
* @param {string} server URL to server.
|
* @param {string} server URL to server.
|
||||||
* @param {string} username Associated username or e-mail to be saved.
|
* @param {string} username Associated username or e-mail to be saved.
|
||||||
* @param {string} password Associated password to be saved.
|
* @param {string} password Associated password to be saved.
|
||||||
|
* @param {string} minimumSecurityLevel `SECURITY_LEVEL` defines which security
|
||||||
|
* level is minimally acceptable for this password.
|
||||||
* @param {object} options Keychain options, iOS only
|
* @param {object} options Keychain options, iOS only
|
||||||
* @return {Promise} Resolves to `true` when successful
|
* @return {Promise} Resolves to `true` when successful
|
||||||
*/
|
*/
|
||||||
|
@ -98,12 +123,14 @@ export function setInternetCredentials(
|
||||||
server: string,
|
server: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
minimumSecurityLevel?: SecMinimumLevel,
|
||||||
options?: Options
|
options?: Options
|
||||||
): Promise {
|
): Promise {
|
||||||
return RNKeychainManager.setInternetCredentialsForServer(
|
return RNKeychainManager.setInternetCredentialsForServer(
|
||||||
server,
|
server,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
getMinimumSecurityLevel(minimumSecurityLevel),
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -145,35 +172,34 @@ function getOptionsArgument(serviceOrOptions?: string | Options) {
|
||||||
: serviceOrOptions;
|
: serviceOrOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMinimumSecurityLevel(minimumSecurityLevel?: SecMinimumLevel) {
|
||||||
|
if (minimumSecurityLevel === undefined) {
|
||||||
|
return SECURITY_LEVEL.ANY;
|
||||||
|
} else {
|
||||||
|
return minimumSecurityLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the `username` and `password` combination for `service`.
|
* Saves the `username` and `password` combination for `service`.
|
||||||
* @param {string} username Associated username or e-mail to be saved.
|
* @param {string} username Associated username or e-mail to be saved.
|
||||||
* @param {string} password Associated password to be saved.
|
* @param {string} password Associated password to be saved.
|
||||||
|
* @param {string} minimumSecurityLevel `SECURITY_LEVEL` defines which security
|
||||||
|
* level is minimally acceptable for this password.
|
||||||
* @param {string|object} serviceOrOptions Reverse domain name qualifier for the service, defaults to `bundleId` or an options object.
|
* @param {string|object} serviceOrOptions Reverse domain name qualifier for the service, defaults to `bundleId` or an options object.
|
||||||
* @return {Promise} Resolves to `true` when successful
|
* @return {Promise} Resolves to `true` when successful
|
||||||
*/
|
*/
|
||||||
export function setGenericPassword(
|
export function setGenericPassword(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
minimumSecurityLevel?: SecMinimumLevel,
|
||||||
serviceOrOptions?: string | Options
|
serviceOrOptions?: string | Options
|
||||||
): Promise {
|
): Promise {
|
||||||
return RNKeychainManager.setGenericPasswordForOptions(
|
return RNKeychainManager.setGenericPasswordForOptions(
|
||||||
getOptionsArgument(serviceOrOptions),
|
getOptionsArgument(serviceOrOptions),
|
||||||
username,
|
username,
|
||||||
password
|
password,
|
||||||
);
|
getMinimumSecurityLevel(minimumSecurityLevel)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the `username` for further use on get requests.
|
|
||||||
* @param {string} username Associated username or e-mail to be saved.
|
|
||||||
* @return {Promise} Resolves to `true` when successful
|
|
||||||
*/
|
|
||||||
export function setUsername(
|
|
||||||
username: string
|
|
||||||
): Promise {
|
|
||||||
return RNKeychainManager.setUsername(
|
|
||||||
username
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue