diff --git a/README.md b/README.md index d1ca7769..3ab7af96 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,5 @@ -# Project no longer mantained. - -As Crypho does not use Cordova for a long time now, it has become clear that we cannot keep maintaining this project any longer, or give it the attention it deserves. -A big thanks to all the contributors. - # SecureStorage plugin for Apache Cordova -[![NPM](https://nodei.co/npm/cordova-plugin-secure-storage.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/cordova-plugin-secure-storage/) - ## Introduction This plugin is for use with [Apache Cordova](http://cordova.apache.org/) and allows your application to securely store secrets @@ -182,6 +175,29 @@ The inverse process is followed on `get`. Native AES is used. Minimum android supported version is 5.0 Lollipop. If you need to support earlier Android versions use version 2.6.8. +##### Android Init Options. +- `packageName` - See [Sharing data between 2 apps on Android](#sharing-data-android) +- `userAuthenticationValidityDuration` - Sets the duration of time (seconds) for which the Private Encryption Key is authorized to be used after the user is successfully authenticated. [KeyGenParameterSpec.Builder#setUserAuthenticationValidityDurationSeconds(int)](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder.html#setUserAuthenticationValidityDurationSeconds(int)) +- `unlockCredentialsTitle` - Custom title for Confirm Credentials screen. See [KeyguardManager#createConfirmDeviceCredentialIntent(title, description)](https://developer.android.com/reference/android/app/KeyguardManager.html#createConfirmDeviceCredentialIntent(java.lang.CharSequence,%20java.lang.CharSequence)) +- `unlockCredentialsDescription` - Custom description for Confirm Credentials screen. + +```js +var ss = new cordova.plugins.SecureStorage( + function() { + console.log("Success"); + }, + function(error) { + console.log("Error " + error); + }, + "my_app", + { + android: { + packageName: "com.test.app1" + } + } +); +``` + ##### Users must have a secure screen-lock set. The plugin will only work correctly if the user has sufficiently secure settings on the lock screen. If not, the plugin will fail to initialize and the failure callback will be called on `init()`. This is because in order to use the Android Credential Storage and create RSA keys the device needs to be somewhat secure. @@ -219,7 +235,7 @@ var _init = function() { _init(); ``` -##### Sharing data between 2 apps on Android. +##### Sharing data between 2 apps on Android. The plugin can be used to share data securely between 2 Android apps. diff --git a/package.json b/package.json index 9f27207c..97442f12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cordova-plugin-secure-storage", - "version": "3.0.2", + "version": "4.0.0", "description": "Secure storage plugin for iOS & Android", "author": "Yiorgis Gozadinos ", "contributors": [ diff --git a/src/android/AES.java b/src/android/AES.java index 03c2e332..cce8caf9 100644 --- a/src/android/AES.java +++ b/src/android/AES.java @@ -1,9 +1,6 @@ package com.crypho.plugins; -import android.util.Log; import android.util.Base64; - -import org.json.JSONException; import org.json.JSONObject; import java.security.Key; @@ -16,67 +13,67 @@ import javax.crypto.KeyGenerator; public class AES { - private static final String CIPHER_MODE = "CCM"; - private static final int KEY_SIZE = 256; - private static final int VERSION = 1; - private static final Cipher CIPHER = getCipher(); + private static final String CIPHER_MODE = "CCM"; + private static final int KEY_SIZE = 256; + private static final int VERSION = 1; + private static final Cipher CIPHER = getCipher(); - public static JSONObject encrypt(byte[] msg, byte[] adata) throws Exception { - byte[] iv, ct, secretKeySpec_enc; - synchronized (CIPHER) { - SecretKeySpec secretKeySpec = generateKeySpec(); - secretKeySpec_enc = secretKeySpec.getEncoded(); - initCipher(Cipher.ENCRYPT_MODE, secretKeySpec, null, adata); - iv = CIPHER.getIV(); - ct = CIPHER.doFinal(msg); - } + public static JSONObject encrypt(byte[] msg, byte[] adata) throws Exception { + byte[] iv, ct, secretKeySpec_enc; + synchronized (CIPHER) { + SecretKeySpec secretKeySpec = generateKeySpec(); + secretKeySpec_enc = secretKeySpec.getEncoded(); + initCipher(Cipher.ENCRYPT_MODE, secretKeySpec, null, adata); + iv = CIPHER.getIV(); + ct = CIPHER.doFinal(msg); + } - JSONObject value = new JSONObject(); - value.put("iv", Base64.encodeToString(iv, Base64.DEFAULT)); - value.put("v", Integer.toString(VERSION)); - value.put("ks", Integer.toString(KEY_SIZE)); - value.put("cipher", "AES"); - value.put("mode", CIPHER_MODE); - value.put("adata", Base64.encodeToString(adata, Base64.DEFAULT)); - value.put("ct", Base64.encodeToString(ct, Base64.DEFAULT)); + JSONObject value = new JSONObject(); + value.put("iv", Base64.encodeToString(iv, Base64.DEFAULT)); + value.put("v", Integer.toString(VERSION)); + value.put("ks", Integer.toString(KEY_SIZE)); + value.put("cipher", "AES"); + value.put("mode", CIPHER_MODE); + value.put("adata", Base64.encodeToString(adata, Base64.DEFAULT)); + value.put("ct", Base64.encodeToString(ct, Base64.DEFAULT)); - JSONObject result = new JSONObject(); - result.put("key", Base64.encodeToString(secretKeySpec_enc, Base64.DEFAULT)); - result.put("value", value); - result.put("native", true); + JSONObject result = new JSONObject(); + result.put("key", Base64.encodeToString(secretKeySpec_enc, Base64.DEFAULT)); + result.put("value", value); + result.put("native", true); - return result; - } + return result; + } - public static String decrypt(byte[] buf, byte[] key, byte[] iv, byte[] adata) throws Exception { - SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES"); - synchronized (CIPHER) { - initCipher(Cipher.DECRYPT_MODE, secretKeySpec, iv, adata); - return new String(CIPHER.doFinal(buf)); - } - } + public static String decrypt(byte[] buf, byte[] key, byte[] iv, byte[] adata) throws Exception { + SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES"); + synchronized (CIPHER) { + initCipher(Cipher.DECRYPT_MODE, secretKeySpec, iv, adata); + return new String(CIPHER.doFinal(buf)); + } + } - private static SecretKeySpec generateKeySpec() throws Exception { - KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); - keyGenerator.init(KEY_SIZE, new SecureRandom()); - SecretKey sc = keyGenerator.generateKey(); - return new SecretKeySpec(sc.getEncoded(), "AES"); - } + private static SecretKeySpec generateKeySpec() throws Exception { + KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); + keyGenerator.init(KEY_SIZE, new SecureRandom()); + SecretKey sc = keyGenerator.generateKey(); + return new SecretKeySpec(sc.getEncoded(), "AES"); + } - private static void initCipher(int cipherMode, Key key, byte[] iv, byte[] adata) throws Exception { - if (iv != null) { - CIPHER.init(cipherMode, key, new IvParameterSpec(iv)); - } else { - CIPHER.init(cipherMode, key); - } - CIPHER.updateAAD(adata); - } + private static void initCipher(int cipherMode, Key key, byte[] iv, byte[] adata) throws Exception { + if (iv != null) { + CIPHER.init(cipherMode, key, new IvParameterSpec(iv)); + } else { + CIPHER.init(cipherMode, key); + } + CIPHER.updateAAD(adata); + } - private static Cipher getCipher() { - try { - return Cipher.getInstance("AES/" + CIPHER_MODE + "/NoPadding"); - } catch (Exception e) { - return null; - } - } + private static Cipher getCipher() { + try { + return Cipher.getInstance("AES/" + CIPHER_MODE + "/NoPadding"); + } catch (Exception e) { + return null; + } + } } diff --git a/src/android/RSA.java b/src/android/RSA.java index 9a93a31f..2e66a9ad 100644 --- a/src/android/RSA.java +++ b/src/android/RSA.java @@ -1,22 +1,37 @@ package com.crypho.plugins; +import android.annotation.TargetApi; import android.content.Context; +import android.os.Build; +import android.security.keystore.KeyInfo; +import android.security.keystore.UserNotAuthenticatedException; import android.util.Log; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; import android.security.KeyPairGeneratorSpec; + import java.math.BigInteger; import java.security.Key; +import java.security.KeyFactory; import java.security.KeyPairGenerator; import java.security.KeyStore; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.RSAKeyGenParameterSpec; import java.util.Calendar; import javax.crypto.Cipher; import javax.security.auth.x500.X500Principal; +import static org.apache.cordova.CordovaActivity.TAG; + public class RSA { private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; private static final Cipher CIPHER = getCipher(); + private static final Integer CERT_VALID_YEARS = 100; + private static final Boolean IS_API_23_AVAILABLE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; + private static final String TAG = "SecureStorage"; public static byte[] encrypt(byte[] buf, String alias) throws Exception { return runCipher(Cipher.ENCRYPT_MODE, alias, buf); @@ -26,30 +41,52 @@ public static byte[] decrypt(byte[] buf, String alias) throws Exception { return runCipher(Cipher.DECRYPT_MODE, alias, buf); } - public static void createKeyPair(Context ctx, String alias) throws Exception { - Calendar notBefore = Calendar.getInstance(); - Calendar notAfter = Calendar.getInstance(); - notAfter.add(Calendar.YEAR, 100); - String principalString = String.format("CN=%s, OU=%s", alias, ctx.getPackageName()); - KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(ctx) - .setAlias(alias) - .setSubject(new X500Principal(principalString)) - .setSerialNumber(BigInteger.ONE) - .setStartDate(notBefore.getTime()) - .setEndDate(notAfter.getTime()) - .setEncryptionRequired() - .setKeySize(2048) - .setKeyType("RSA") - .build(); - KeyPairGenerator kpGenerator = KeyPairGenerator.getInstance("RSA", KEYSTORE_PROVIDER); + public static void createKeyPair(Context ctx, String alias, Integer userAuthenticationValidityDuration) throws Exception { + AlgorithmParameterSpec spec = IS_API_23_AVAILABLE ? getInitParams(alias, userAuthenticationValidityDuration) : getInitParamsLegacy(ctx, alias); + + KeyPairGenerator kpGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE_PROVIDER); kpGenerator.initialize(spec); kpGenerator.generateKeyPair(); } - public static boolean isEntryAvailable(String alias) { + /** + * Check if Encryption Keys are available and secure. + * + * @param alias + * @return boolean + */ + public static boolean encryptionKeysAvailable(String alias) { try { - return loadKey(Cipher.ENCRYPT_MODE, alias) != null; + Key privateKey = loadKey(Cipher.DECRYPT_MODE, alias); + if (privateKey == null) { + return false; + } + KeyInfo keyInfo; + KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), KEYSTORE_PROVIDER); + keyInfo = factory.getKeySpec(privateKey, KeyInfo.class); + return keyInfo.isInsideSecureHardware(); + } catch (Exception e) { + Log.i(TAG, "Checking encryption keys failed.", e); + return false; + } + } + + /** + * Check if we need to prompt for User's Credentials + * + * @param alias + * @return + */ + public static boolean userAuthenticationRequired(String alias) { + try { + // Do a quick encrypt/decrypt test + byte[] encrypted = encrypt(alias.getBytes(), alias); + decrypt(encrypted, alias); + return false; + } catch (UserNotAuthenticatedException noAuthEx) { + return true; } catch (Exception e) { + // Other return false; } } @@ -65,6 +102,11 @@ private static byte[] runCipher(int cipherMode, String alias, byte[] buf) throws private static Key loadKey(int cipherMode, String alias) throws Exception { KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); keyStore.load(null, null); + + if (!keyStore.containsAlias(alias)) { + throw new Exception("KeyStore doesn't contain alias: " + alias); + } + Key key; switch (cipherMode) { case Cipher.ENCRYPT_MODE: @@ -73,13 +115,14 @@ private static Key loadKey(int cipherMode, String alias) throws Exception { throw new Exception("Failed to load the public key for " + alias); } break; - case Cipher.DECRYPT_MODE: + case Cipher.DECRYPT_MODE: key = keyStore.getKey(alias, null); if (key == null) { throw new Exception("Failed to load the private key for " + alias); } break; - default : throw new Exception("Invalid cipher mode parameter"); + default: + throw new Exception("Invalid cipher mode parameter"); } return key; } @@ -91,4 +134,48 @@ private static Cipher getCipher() { return null; } } + + /** + * Generate Encryption Keys Parameter Spec + * + * @param alias String + * @return AlgorithmParameterSpec + * @// TODO: 2019-07-08 Fix setUserAuthenticationValidityDurationSeconds workaround + */ + @TargetApi(Build.VERSION_CODES.M) + private static AlgorithmParameterSpec getInitParams(String alias, Integer userAuthenticationValidityDuration) { + Calendar notAfter = Calendar.getInstance(); + notAfter.add(Calendar.YEAR, CERT_VALID_YEARS); + + return new KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) + .setCertificateNotBefore(Calendar.getInstance().getTime()) + .setCertificateNotAfter(notAfter.getTime()) + .setAlgorithmParameterSpec(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4)) + .setUserAuthenticationRequired(true) + .setUserAuthenticationValidityDurationSeconds(userAuthenticationValidityDuration) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .setBlockModes(KeyProperties.BLOCK_MODE_ECB) + .build(); + } + + /** + * Generate Encryption Keys Parameter Spec + * Fallback to legacy (pre API 23) Spec Generator + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static AlgorithmParameterSpec getInitParamsLegacy(Context ctx, String alias) throws Exception { + Calendar notAfter = Calendar.getInstance(); + notAfter.add(Calendar.YEAR, CERT_VALID_YEARS); + + return new KeyPairGeneratorSpec.Builder(ctx) + .setAlias(alias) + .setSubject(new X500Principal(String.format("CN=%s, OU=%s", alias, ctx.getPackageName()))) + .setSerialNumber(BigInteger.ONE) + .setStartDate(Calendar.getInstance().getTime()) + .setEndDate(notAfter.getTime()) + .setEncryptionRequired() + .setKeySize(2048) + .setKeyType(KeyProperties.KEY_ALGORITHM_RSA) + .build(); + } } \ No newline at end of file diff --git a/src/android/SecureStorage.java b/src/android/SecureStorage.java index ceb6307a..585633ba 100644 --- a/src/android/SecureStorage.java +++ b/src/android/SecureStorage.java @@ -3,6 +3,7 @@ import java.lang.reflect.Method; import java.util.Hashtable; +import android.provider.Settings; import android.util.Log; import android.util.Base64; import android.os.Build; @@ -16,21 +17,22 @@ import org.json.JSONException; import org.json.JSONObject; import org.json.JSONArray; -import javax.crypto.Cipher; public class SecureStorage extends CordovaPlugin { private static final String TAG = "SecureStorage"; private static final boolean SUPPORTED = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + private static final Integer DEFAULT_AUTHENTICATION_VALIDITY_TIME = 60 * 60 * 24; // Fallback to 24h. Workaround to avoid asking for credentials to "often" private static final String MSG_NOT_SUPPORTED = "API 21 (Android 5.0 Lollipop) is required. This device is running API " + Build.VERSION.SDK_INT; private static final String MSG_DEVICE_NOT_SECURE = "Device is not secure"; + private static final String MSG_KEYS_FAILED = "Generate RSA Encryption Keys failed. "; private Hashtable SERVICE_STORAGE = new Hashtable(); private String INIT_SERVICE; private String INIT_PACKAGENAME; - private volatile CallbackContext initContext, secureDeviceContext; - private volatile boolean initContextRunning = false; + private volatile CallbackContext secureDeviceContext, generateKeysContext, unlockCredentialsContext; + private volatile boolean generateKeysContextRunning = false; @Override public void onResume(boolean multitasking) { @@ -43,25 +45,15 @@ public void onResume(boolean multitasking) { secureDeviceContext = null; } - if (initContext != null && !initContextRunning) { + if (unlockCredentialsContext != null) { cordova.getThreadPool().execute(new Runnable() { public void run() { - initContextRunning = true; - try { - String alias = service2alias(INIT_SERVICE); - if (!RSA.isEntryAvailable(alias)) { - //Solves Issue #96. The RSA key may have been deleted by changing the lock type. - getStorage(INIT_SERVICE).clear(); - RSA.createKeyPair(getContext(), alias); - } - initSuccess(initContext); - } catch (Exception e) { - Log.e(TAG, "Init failed :", e); - initContext.error(e.getMessage()); - } finally { - initContext = null; - initContextRunning = false; + String alias = service2alias(INIT_SERVICE); + if (RSA.userAuthenticationRequired(alias)) { + unlockCredentialsContext.error("User not authenticated"); } + unlockCredentialsContext.success(); + unlockCredentialsContext = null; } }); } @@ -69,7 +61,7 @@ public void run() { @Override public boolean execute(String action, CordovaArgs args, final CallbackContext callbackContext) throws JSONException { - if(!SUPPORTED){ + if (!SUPPORTED) { Log.w(TAG, MSG_NOT_SUPPORTED); callbackContext.error(MSG_NOT_SUPPORTED); return false; @@ -80,7 +72,7 @@ public boolean execute(String action, CordovaArgs args, final CallbackContext ca String packageName = options.optString("packageName", getContext().getPackageName()); Context ctx = null; - + // Solves #151. By default, we use our own ApplicationContext // If packageName is provided, we try to get the Context of another Application with that packageName try { @@ -98,13 +90,23 @@ public boolean execute(String action, CordovaArgs args, final CallbackContext ca SharedPreferencesHandler PREFS = new SharedPreferencesHandler(alias, ctx); SERVICE_STORAGE.put(service, PREFS); - if (!isDeviceSecure()) { Log.e(TAG, MSG_DEVICE_NOT_SECURE); callbackContext.error(MSG_DEVICE_NOT_SECURE); - } else if (!RSA.isEntryAvailable(alias)) { - initContext = callbackContext; - unlockCredentials(); + } + if (!RSA.encryptionKeysAvailable(alias)) { + // Encryption Keys aren't available, proceed to generate them + Integer userAuthenticationValidityDuration = options.optInt("userAuthenticationValidityDuration", DEFAULT_AUTHENTICATION_VALIDITY_TIME); + + generateKeysContext = callbackContext; + generateEncryptionKeys(userAuthenticationValidityDuration); + } else if (RSA.userAuthenticationRequired(alias)) { + // User has to confirm authentication via device credentials. + String title = options.optString("unlockCredentialsTitle", null); + String description = options.optString("unlockCredentialsDescription", null); + + unlockCredentialsContext = callbackContext; + unlockCredentials(title, description); } else { initSuccess(callbackContext); } @@ -161,8 +163,10 @@ public void run() { return true; } if ("secureDevice".equals(action)) { + // Open the Security Settings screen. The app developer should inform the user about + // the security requirements of the app and initialize again after the user has changed the screen-lock settings secureDeviceContext = callbackContext; - unlockCredentials(); + secureDevice(); return true; } if ("remove".equals(action)) { @@ -187,7 +191,7 @@ public void run() { } private boolean isDeviceSecure() { - KeyguardManager keyguardManager = (KeyguardManager)(getContext().getSystemService(Context.KEYGUARD_SERVICE)); + KeyguardManager keyguardManager = (KeyguardManager) (getContext().getSystemService(Context.KEYGUARD_SERVICE)); try { Method isSecure = null; isSecure = keyguardManager.getClass().getMethod("isDeviceSecure"); @@ -199,7 +203,7 @@ private boolean isDeviceSecure() { private String service2alias(String service) { String res = INIT_PACKAGENAME + "." + service; - return res; + return res; } private SharedPreferencesHandler getStorage(String service) { @@ -210,15 +214,69 @@ private void initSuccess(CallbackContext context) { context.success(); } - private void unlockCredentials() { + /** + * Create the Confirm Credentials screen. + * You can customize the title and description or Android will provide a generic one for you if you leave it null + * + * @param title + * @param description + * @// TODO: 2019-07-08 Use BiometricPrompt#setDeviceCredentialAllowed for API 29+ + */ + private void unlockCredentials(String title, String description) { cordova.getActivity().runOnUiThread(new Runnable() { public void run() { - Intent intent = new Intent("com.android.credentials.UNLOCK"); + KeyguardManager keyguardManager = (KeyguardManager) (getContext().getSystemService(Context.KEYGUARD_SERVICE)); + Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(title, description); startActivity(intent); } }); } + /** + * Generate Encryption Keys in the background. + * + * @param userAuthenticationValidityDuration User authentication validity duration in seconds + */ + private void generateEncryptionKeys(Integer userAuthenticationValidityDuration) { + if (generateKeysContext != null && !generateKeysContextRunning) { + cordova.getThreadPool().execute(new Runnable() { + public void run() { + generateKeysContextRunning = true; + try { + String alias = service2alias(INIT_SERVICE); + //Solves Issue #96. The RSA key may have been deleted by changing the lock type. + getStorage(INIT_SERVICE).clear(); + RSA.createKeyPair(getContext(), alias, userAuthenticationValidityDuration); + generateKeysContext.success(); + } catch (Exception e) { + Log.e(TAG, MSG_KEYS_FAILED, e); + generateKeysContext.error(MSG_KEYS_FAILED + e.getMessage()); + } finally { + generateKeysContext = null; + generateKeysContextRunning = false; + } + } + }); + } + } + + /** + * Open Security settings screen. + */ + private void secureDevice() { + cordova.getActivity().runOnUiThread(new Runnable() { + public void run() { + try { + Intent intent = new Intent(Settings.ACTION_SECURITY_SETTINGS); + startActivity(intent); + } catch (Exception e) { + Log.e(TAG, "Error opening Security settings to secure device : ", e); + secureDeviceContext.error(e.getMessage()); + } + } + }); + } + private Context getContext() { return cordova.getActivity().getApplicationContext(); }