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]).
This commit is contained in:
Igor Mandrigin 2018-12-13 21:00:22 +01:00
parent 1c35579a36
commit 50090a1fd3
7 changed files with 222 additions and 43 deletions

View File

@ -285,7 +285,7 @@ RCT_EXPORT_METHOD(getSupportedBiometryType:(RCTPromiseResolveBlock)resolve rejec
}
#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);
NSDictionary *attributes = attributes = @{
@ -358,7 +358,7 @@ RCT_EXPORT_METHOD(resetGenericPasswordForOptions:(NSDictionary *)options resolve
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];

View File

@ -20,7 +20,6 @@ import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAESCBC;
import com.oblador.keychain.exceptions.CryptoFailedException;
import com.oblador.keychain.exceptions.EmptyParameterException;
import com.oblador.keychain.exceptions.KeyStoreAccessException;
import com.oblador.keychain.DeviceAvailability;
import java.util.HashMap;
import java.util.Map;
@ -56,16 +55,35 @@ public class KeychainModule extends ReactContextBaseJavaModule {
}
@ReactMethod
public void setGenericPasswordForOptions(String service, String username, String password, Promise promise) {
public void getSecurityLevel(Promise promise) {
try {
CipherStorage storage = getCipherStorageForCurrentAPILevel(SecurityLevel.ANY);
if (!storage.securityLevel().satisfiesSafetyThreshold(SecurityLevel.SECURE_SOFTWARE)) {
promise.resolve(SecurityLevel.ANY.name());
}
if (isSecureHardwareAvailable()) {
promise.resolve(SecurityLevel.SECURE_HARDWARE.name());
} else {
promise.resolve(SecurityLevel.SECURE_SOFTWARE.name());
}
} catch (CryptoFailedException e) {
promise.resolve(SecurityLevel.ANY.name());
}
}
@ReactMethod
public void setGenericPasswordForOptions(String service, String username, String password, String minimumSecurityLevel, Promise promise) {
try {
SecurityLevel level = SecurityLevel.valueOf(minimumSecurityLevel);
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
throw new EmptyParameterException("you passed empty or null username/password");
}
service = getDefaultServiceIfNull(service);
CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel();
CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel(level);
EncryptionResult result = currentCipherStorage.encrypt(service, username, password);
EncryptionResult result = currentCipherStorage.encrypt(service, username, password, level);
prefsStorage.storeEncryptedEntry(service, result);
promise.resolve(true);
@ -83,7 +101,7 @@ public class KeychainModule extends ReactContextBaseJavaModule {
try {
service = getDefaultServiceIfNull(service);
CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel();
CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel(SecurityLevel.ANY);
final DecryptionResult decryptionResult;
ResultSet resultSet = prefsStorage.getEncryptedEntry(service);
@ -103,7 +121,8 @@ public class KeychainModule extends ReactContextBaseJavaModule {
// decrypt using the older cipher storage
decryptionResult = oldCipherStorage.decrypt(service, resultSet.usernameBytes, resultSet.passwordBytes);
// encrypt using the current cipher storage
EncryptionResult encryptionResult = currentCipherStorage.encrypt(service, decryptionResult.username, decryptionResult.password);
// TODO: IGORM: fix this thing
EncryptionResult encryptionResult = currentCipherStorage.encrypt(service, decryptionResult.username, decryptionResult.password, SecurityLevel.ANY);
// store the encryption result
prefsStorage.storeEncryptedEntry(service, encryptionResult);
// clean up the old cipher storage
@ -165,7 +184,7 @@ public class KeychainModule extends ReactContextBaseJavaModule {
@ReactMethod
public void setInternetCredentialsForServer(@NonNull String server, String username, String password, ReadableMap unusedOptions, Promise promise) {
setGenericPasswordForOptions(server, username, password, promise);
setGenericPasswordForOptions(server, username, password, minimumSecurityLevel, promise);
}
@ReactMethod
@ -194,20 +213,24 @@ public class KeychainModule extends ReactContextBaseJavaModule {
}
// The "Current" CipherStorage is the cipherStorage with the highest API level that is lower than or equal to the current API level
private CipherStorage getCipherStorageForCurrentAPILevel() throws CryptoFailedException {
private CipherStorage getCipherStorageForCurrentAPILevel(SecurityLevel level) throws CryptoFailedException {
int currentAPILevel = Build.VERSION.SDK_INT;
CipherStorage currentCipherStorage = null;
for (CipherStorage cipherStorage : cipherStorageMap.values()) {
int cipherStorageAPILevel = cipherStorage.getMinSupportedApiLevel();
// Is the cipherStorage supported on the current API level?
boolean isSupported = (cipherStorageAPILevel <= currentAPILevel);
boolean guaranteesSecurityLevel = cipherStorage.securityLevel().satisfiesSafetyThreshold(level);
if (!isSupported || !guaranteesSecurityLevel) {
continue;
}
// 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;
}
}
if (currentCipherStorage == null) {
throw new CryptoFailedException("Unsupported Android SDK " + Build.VERSION.SDK_INT);
throw new CryptoFailedException("Unsupported Android SDK or no storage providing enough guarantees" + Build.VERSION.SDK_INT);
}
return currentCipherStorage;
}
@ -220,6 +243,15 @@ public class KeychainModule extends ReactContextBaseJavaModule {
return DeviceAvailability.isFingerprintAuthAvailable(getReactApplicationContext());
}
private boolean isSecureHardwareAvailable() {
try {
return getCipherStorageForCurrentAPILevel(SecurityLevel.ANY).supportsSecureHardware();
} catch (CryptoFailedException e) {
return false;
}
}
@NonNull
private String getDefaultServiceIfNull(String service) {
return service == null ? EMPTY_STRING : service;

View File

@ -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;
}
}

View File

@ -2,6 +2,7 @@ package com.oblador.keychain.cipherStorage;
import android.support.annotation.NonNull;
import com.oblador.keychain.SecurityLevel;
import com.oblador.keychain.exceptions.CryptoFailedException;
import com.oblador.keychain.exceptions.KeyStoreAccessException;
@ -31,7 +32,7 @@ public interface CipherStorage {
}
}
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;
@ -40,4 +41,8 @@ public interface CipherStorage {
String getCipherStorageName();
int getMinSupportedApiLevel();
SecurityLevel securityLevel();
boolean supportsSecureHardware();
}

View File

@ -10,6 +10,7 @@ import com.facebook.crypto.CryptoConfig;
import com.facebook.crypto.Entity;
import com.facebook.crypto.keychain.KeyChain;
import com.facebook.react.bridge.ReactApplicationContext;
import com.oblador.keychain.SecurityLevel;
import com.oblador.keychain.exceptions.CryptoFailedException;
import java.nio.charset.Charset;
@ -35,7 +36,23 @@ public class CipherStorageFacebookConceal implements CipherStorage {
}
@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)) {
// TODO: IGORM: refactor to another exception type!
throw new CryptoFailedException(String.format("Insufficient security level (wants %s; got %s)", level, this.securityLevel()));
}
if (!crypto.isAvailable()) {
throw new CryptoFailedException("Crypto is missing");
}

View File

@ -3,9 +3,12 @@ package com.oblador.keychain.cipherStorage;
import android.annotation.TargetApi;
import android.os.Build;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyInfo;
import android.security.keystore.KeyProperties;
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.KeyStoreAccessException;
@ -21,16 +24,20 @@ import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.UnrecoverableKeyException;
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.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
@TargetApi(Build.VERSION_CODES.M)
public class CipherStorageKeystoreAESCBC implements CipherStorage {
public static final String TAG = "KeystoreAESCBC";
public static final String CIPHER_STORAGE_NAME = "KeystoreAESCBC";
public static final String DEFAULT_SERVICE = "RN_KEYCHAIN_DEFAULT_ALIAS";
public static final String KEYSTORE_TYPE = "AndroidKeyStore";
@ -54,7 +61,32 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
}
@Override
public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password) throws CryptoFailedException {
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|InvalidKeySpecException 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)
@Override
public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password, SecurityLevel level) throws CryptoFailedException {
service = getDefaultServiceIfEmpty(service);
try {
@ -79,21 +111,34 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
}
}
private void generateKeyAndStoreUnderAlias(@NonNull String service) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException {
AlgorithmParameterSpec spec = 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)
.build();
@TargetApi(Build.VERSION_CODES.M)
private boolean validateKeySecurityLevel(SecurityLevel level, SecretKey generatedKey) throws NoSuchAlgorithmException,
NoSuchProviderException, InvalidKeySpecException {
SecretKeyFactory factory = SecretKeyFactory.getInstance(generatedKey.getAlgorithm(), KEYSTORE_TYPE);
KeyInfo keyInfo;
keyInfo = (KeyInfo) factory.getKeySpec(generatedKey, KeyInfo.class);
KeyGenerator generator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, KEYSTORE_TYPE);
generator.init(spec);
if (level == SecurityLevel.SECURE_HARDWARE && !keyInfo.isInsideSecureHardware()) {
Log.w(TAG, "Could not create a key inside secure hardware (SECURE_HARDWARE), even though it is required");
return false;
}
generator.generateKey();
return true;
}
private void generateKeyAndStoreUnderAlias(@NonNull String service) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException, CryptoFailedException {
// Firstly, try to generate the key as safe as possible (strongbox).
// see https://developer.android.com/training/articles/keystore#HardwareSecurityModule
SecretKey secretKey = tryGenerateStrongBoxSecurityKey(service);
if (secretKey == null) {
// If that is not possible, we generate the key in a regular way
// (it still might be generated in hardware, but not in StrongBox)
secretKey = tryGenerateRegularSecurityKey(service);
}
if(!validateKeySecurityLevel(level, secretKey)) {
throw new CryptoFailedException("Cannot generate keys with required security guarantees");
}
}
@Override
@ -198,4 +243,46 @@ public class CipherStorageKeystoreAESCBC implements CipherStorage {
private String getDefaultServiceIfEmpty(@NonNull String 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);
}
}

View File

@ -1,6 +1,12 @@
import { NativeModules, Platform } from 'react-native';
const { RNKeychainManager } = NativeModules;
export const SECURITY_LEVEL = {
ANY: 'ANY',
SECURE_SOFTWARE: 'SECURE_SOFTWARE',
SECURE_HARDWARE: 'SECURE_HARDWARE',
};
export const ACCESSIBLE = {
WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
@ -33,6 +39,11 @@ export const BIOMETRY_TYPE = {
FINGERPRINT: 'Fingerprint',
};
type SecMinimumLevel =
| 'ANY'
| 'SECURE_SOFTWARE'
| 'TEE' ;
type SecAccessible =
| 'AccessibleWhenUnlocked'
| 'AccessibleAfterFirstUnlock'
@ -62,6 +73,18 @@ type Options = {
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
* 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} username Associated username or e-mail 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
* @return {Promise} Resolves to `true` when successful
*/
@ -98,12 +123,14 @@ export function setInternetCredentials(
server: string,
username: string,
password: string,
minimumSecurityLevel?: SecMinimumLevel,
options?: Options
): Promise {
return RNKeychainManager.setInternetCredentialsForServer(
server,
username,
password,
getMinimumSecurityLevel(minimumSecurityLevel),
options
);
}
@ -156,35 +183,34 @@ function getOptionsArgument(serviceOrOptions?: string | Options) {
: serviceOrOptions;
}
function getMinimumSecurityLevel(minimumSecurityLevel?: SecMinimumLevel) {
if (minimumSecurityLevel === undefined) {
return SECURITY_LEVEL.ANY;
} else {
return minimumSecurityLevel
}
}
/**
* Saves the `username` and `password` combination for `service`.
* @param {string} username Associated username or e-mail 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.
* @return {Promise} Resolves to `true` when successful
*/
export function setGenericPassword(
username: string,
password: string,
minimumSecurityLevel?: SecMinimumLevel,
serviceOrOptions?: string | Options
): Promise {
return RNKeychainManager.setGenericPasswordForOptions(
getOptionsArgument(serviceOrOptions),
username,
password
);
}
/**
* 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
password,
getMinimumSecurityLevel(minimumSecurityLevel)
);
}