Llaves, credenciales y almacenamiento en Android

En la publicación anterior sobre la seguridad de los datos del usuario de Android, analizamos el cifrado de datos a través de un código de acceso proporcionado por el usuario. Este tutorial cambiará el enfoque hacia el almacenamiento de credenciales y claves. Comenzaré por introducir las credenciales de la cuenta y terminaré con un ejemplo de protección de datos usando KeyStore.

A menudo, cuando se trabaja con un servicio de terceros, se requerirá algún tipo de autenticación. Esto puede ser tan simple como /iniciar sesión punto final que acepta un nombre de usuario y contraseña. 

Al principio, parece que una solución simple es crear una IU que solicite al usuario que inicie sesión y luego capturar y almacenar sus credenciales de inicio de sesión. Sin embargo, esta no es la mejor práctica porque nuestra aplicación no debería necesitar conocer las credenciales de una cuenta de terceros. En su lugar, podemos usar el Administrador de cuentas, que delega el manejo de esa información confidencial para nosotros.

Gerente de cuentas

El Administrador de cuentas es un ayudante centralizado para las credenciales de la cuenta de usuario, de modo que su aplicación no tiene que lidiar con contraseñas directamente. A menudo proporciona un token en lugar del nombre de usuario y la contraseña reales que se pueden usar para realizar solicitudes autenticadas a un servicio. Un ejemplo es cuando se solicita un token OAuth2.. 

A veces, toda la información requerida ya está almacenada en el dispositivo, y otras veces el Administrador de cuentas deberá llamar a un servidor para obtener un token actualizado. Es posible que haya visto el Cuentas sección en la configuración de su dispositivo para varias aplicaciones. Podemos obtener esa lista de cuentas disponibles como esta:

AccountManager accountManager = AccountManager.get (this); Cuenta [] cuentas = accountManager.getAccounts ();

El código requerirá la android.permission.GET_ACCOUNTS permiso. Si está buscando una cuenta específica, puede encontrarla así:

AccountManager accountManager = AccountManager.get (this); Cuenta [] cuentas = accountManager.getAccountsByType ("com.google");

Una vez que tenga la cuenta, puede recuperar un token de la cuenta llamando al getAuthToken (Cuenta, Cadena, Paquete, Actividad, AccountManagerCallback, Handler) método. El token se puede utilizar para realizar solicitudes de API autenticadas a un servicio. Esta podría ser una API REST donde pasas un parámetro de token durante una solicitud HTTPS, sin tener que conocer los detalles de la cuenta privada del usuario.

Debido a que cada servicio tendrá una forma diferente de autenticar y almacenar las credenciales privadas, el Administrador de cuentas proporciona módulos de autenticador para que un servicio de terceros los implemente. Si bien Android tiene implementaciones para muchos servicios populares, significa que puede escribir su propio autenticador para manejar la autenticación de la cuenta de su aplicación y el almacenamiento de credenciales. Esto le permite asegurarse de que las credenciales están cifradas. Tenga en cuenta que esto también significa que las credenciales en el Administrador de cuentas que son utilizadas por otros servicios pueden almacenarse en texto claro, haciéndolas visibles a cualquier persona que haya rooteado su dispositivo..

En lugar de credenciales simples, hay ocasiones en las que tendrá que tratar con una clave o un certificado para una persona o entidad, por ejemplo, cuando un tercero le envíe un archivo de certificado que debe conservar. El escenario más común es cuando una aplicación necesita autenticarse en el servidor de una organización privada. 

En el siguiente tutorial, analizaremos el uso de certificados para la autenticación y las comunicaciones seguras, pero todavía quiero saber cómo almacenar estos elementos mientras tanto. La API de Keychain se creó originalmente para ese uso muy específico: la instalación de una clave privada o un par de certificados desde un archivo PKCS # 12.

El llavero

Introducido en Android 4.0 (Nivel de API 14), la API de Keychain se ocupa de la administración de claves. En concreto, funciona con Llave privada y X509Certificado objetos y proporciona un contenedor más seguro que usar el almacenamiento de datos de su aplicación. Esto se debe a que los permisos para las claves privadas solo permiten que su propia aplicación acceda a las claves y solo después de la autorización del usuario. Esto significa que debe configurarse una pantalla de bloqueo en el dispositivo antes de poder utilizar el almacenamiento de credenciales. Además, los objetos en el llavero pueden estar vinculados a hardware seguro, si está disponible. 

El código para instalar un certificado es el siguiente:

Intención intención = KeyChain.createInstallIntent (); byte [] p12Bytes = //… leído de un archivo, como example.pfx o example.p12… intent.putExtra (KeyChain.EXTRA_PKCS12, p12Bytes); startActivity (intención);

Se le solicitará al usuario una contraseña para acceder a la clave privada y una opción para nombrar el certificado. Para recuperar la clave, el siguiente código presenta una IU que le permite al usuario elegir de la lista de claves instaladas.

KeyChain.choosePrivateKeyAlias ​​(this, this, new String [] "RSA", null, null, -1, null);

Una vez que se realiza la elección, se devuelve un nombre de alias de cadena en el alias (alias String final) devolución de llamada donde puede acceder a la clave privada o cadena de certificados directamente.

La clase pública KeychainTest extiende los Implementos de actividad…, KeyChainAliasCallback //… @Override alias de vacío público (alias de cadena final) Log.e ("MyApp", "Alias ​​is" + alias); intente PrivateKey privateKey = KeyChain.getPrivateKey (this, alias); X509Certificate [] certificateChain = KeyChain.getCertificateChain (this, alias);  captura…  //… 

Con ese conocimiento, veamos cómo podemos usar el almacenamiento de credenciales para guardar sus propios datos confidenciales..

El KeyStore

En el tutorial anterior, analizamos la protección de datos a través de un código de acceso proporcionado por el usuario. Este tipo de configuración es buena, pero los requisitos de la aplicación a menudo evitan que los usuarios inicien sesión cada vez y recuerden un código de acceso adicional. 

Ahí es donde se puede utilizar la API de KeyStore. Desde la API 1, KeyStore ha sido utilizado por el sistema para almacenar las credenciales de WiFi y VPN. A partir de 4.3 (API 18), le permite trabajar con sus propias claves asimétricas específicas de la aplicación, y en Android M (API 23) puede almacenar una clave simétrica AES. Entonces, mientras que la API no permite almacenar cadenas sensibles directamente, estas claves pueden almacenarse y luego usarse para cifrar cadenas. 

El beneficio de almacenar una clave en el KeyStore es que permite operar las claves sin exponer el contenido secreto de esa clave; Los datos clave no entran en el espacio de la aplicación. Recuerde que las claves están protegidas por permisos para que solo su aplicación pueda acceder a ellas, y que además pueden contar con respaldo de hardware seguro si el dispositivo es capaz. Esto crea un contenedor que dificulta la extracción de claves de un dispositivo. 

Generar una nueva clave aleatoria

Para este ejemplo, en lugar de generar una clave AES a partir de un código de acceso provisto por el usuario, podemos generar automáticamente una clave aleatoria que estará protegida en el KeyStore. Podemos hacer esto creando un Generador de llaves instancia, establecido en el "AndroidKeyStore" proveedor.

// Generar una clave y almacenarla en el KeyStore final KeyGenerator keyGenerator = KeyGenerator.getInstance (KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); última KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder ( "MyKeyAlias", KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) .setBlockModes (KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings (KeyProperties.ENCRYPTION_PADDING_NONE) //.setUserAuthenticationRequired(true) // pantalla de bloqueo requiere, invalidado si la pantalla de bloqueo está deshabilitada //.setUserAuthenticationValidityDurationSeconds(120) // solo está disponible x segundos desde la autenticación de contraseña. -1 requiere huella dactilar: cada vez .setRandomizedEncryptionRequired (true) // texto cifrado diferente para el mismo texto simple en cada llamada .build (); keyGenerator.init (keyGenParameterSpec); keyGenerator.generateKey ();

Partes importantes a mirar aquí son las .setUserAuthenticationRequired (true) y .setUserAuthenticationValidityDurationSeconds (120) presupuesto. Esto requiere que se configure una pantalla de bloqueo y que la llave se bloquee hasta que el usuario se haya autenticado. 

Mirando la documentación para .setUserAuthenticationValidityDurationSeconds (), verá que significa que la clave solo está disponible durante un cierto número de segundos a partir de la autenticación de contraseña, y que se pasa -1 requiere autenticación de huellas dactilares cada vez que desee acceder a la clave. Habilitar el requisito de autenticación también tiene el efecto de revocar la clave cuando el usuario elimina o cambia la pantalla de bloqueo. 

Debido a que almacenar una clave no protegida junto con los datos encriptados es como colocar una llave de la casa debajo del felpudo, estas opciones intentan proteger la clave en reposo en caso de que se comprometa un dispositivo. Un ejemplo podría ser un volcado de datos fuera de línea del dispositivo. Sin que la contraseña sea conocida para el dispositivo, esos datos se vuelven inútiles.

los .setRandomizedEncryptionRequired (true) La opción habilita el requisito de que haya suficiente aleatorización (un nuevo IV aleatorio cada vez) de modo que si los mismos datos se cifran por segunda vez, la salida cifrada seguirá siendo diferente. Esto evita que un atacante obtenga pistas sobre el texto cifrado basándose en la alimentación de los mismos datos. 

Otra opción a tener en cuenta es setUserAuthenticationValidWhileOnBody (boolean remainsValid), que bloquea la llave una vez que el dispositivo ha detectado que ya no está en la persona.

Cifrado de datos

Ahora que la clave está almacenada en el KeyStore, podemos crear un método que encripta los datos usando la Cifrar objeto, dada la Llave secreta. Se devolverá un HashMap que contiene los datos cifrados y un IV aleatorio que será necesario para descifrar los datos. Los datos cifrados, junto con el IV, se pueden guardar en un archivo o en las preferencias compartidas.

HashMap privado encrypt (final byte [] decryptedBytes) final HashMap map = new HashMap(); try // Obtenga la clave final KeyStore keyStore = KeyStore.getInstance ("AndroidKeyStore"); keyStore.load (null); final KeyStore.SecretKeyEntry secretKeyEntry = (KeyStore.SecretKeyEntry) keyStore.getEntry ("MyKeyAlias", null); SecretKey final secretKey = secretKeyEntry.getSecretKey (); // Cifrado de datos Cipher final cipher = Cipher.getInstance ("AES / GCM / NoPadding"); cipher.init (Cipher.ENCRYPT_MODE, secretKey); byte final [] ivBytes = cipher.getIV (); byte final [] encryptedBytes = cipher.doFinal (decryptedBytes); map.put ("iv", ivBytes); map.put ("encriptado", encryptedBytes);  catch (Throwable e) e.printStackTrace (); mapa de retorno; 

Descifrando a una matriz de bytes

Para el descifrado, se aplica lo contrario. los Cifrar objeto se inicializa utilizando el DECRYPT_MODE constante, y un descifrado byte[] la matriz se devuelve.

byte privado [] descifrar (HashMap final mapa) byte [] decryptedBytes = null; try // Obtenga la clave final KeyStore keyStore = KeyStore.getInstance ("AndroidKeyStore"); keyStore.load (null); final KeyStore.SecretKeyEntry secretKeyEntry = (KeyStore.SecretKeyEntry) keyStore.getEntry ("MyKeyAlias", null); SecretKey final secretKey = secretKeyEntry.getSecretKey (); // Extraer información del byte final del mapa [] encryptedBytes = map.get ("encrypted"); byte final [] ivBytes = map.get ("iv"); // Descifrar los datos Cipher final cipher = Cipher.getInstance ("AES / GCM / NoPadding"); especificación GCMParameterSpec final = nueva GCMParameterSpec (128, ivBytes); cipher.init (Cipher.DECRYPT_MODE, secretKey, spec); decryptedBytes = cipher.doFinal (encryptedBytes);  catch (Throwable e) e.printStackTrace ();  return decryptedBytes; 

Probando el ejemplo

Ahora podemos probar nuestro ejemplo.!

@TargetApi (Build.VERSION_CODES.M) private void testEncryption () try // Genere una clave y guárdela en el KeyStore final KeyGenerator keyGenerator = KeyGenerator.getInstance (KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); última KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder ( "MyKeyAlias", KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) .setBlockModes (KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings (KeyProperties.ENCRYPTION_PADDING_NONE) //.setUserAuthenticationRequired(true) // pantalla de bloqueo requiere, invalidado si la pantalla de bloqueo está deshabilitada //.setUserAuthenticationValidityDurationSeconds(120) // solo está disponible x segundos desde la autenticación de contraseña. -1 requiere huella dactilar: cada vez .setRandomizedEncryptionRequired (true) // texto cifrado diferente para el mismo texto simple en cada llamada .build (); keyGenerator.init (keyGenParameterSpec); keyGenerator.generateKey (); // Test final HashMap map = encrypt ("Mi cadena muy sensible!". getBytes ("UTF-8")); byte final [] decryptedBytes = descifrar (mapa); String final decryptedString = new String (decryptedBytes, "UTF-8"); Log.e ("MyApp", "La cadena descifrada es" + decryptedString);  catch (Throwable e) e.printStackTrace (); 

Uso de claves asimétricas RSA para dispositivos antiguos

Esta es una buena solución para almacenar datos para versiones M y superiores, pero ¿qué sucede si su aplicación admite versiones anteriores? Si bien las claves simétricas AES no son compatibles con M, las claves asimétricas RSA sí lo son. Eso significa que podemos usar claves RSA y cifrado para lograr lo mismo. 

La diferencia principal aquí es que un par de llaves asimétricas contiene dos claves, una privada y una pública, donde la clave pública cifra los datos y la clave privada los descifra. UNA KeyPairGeneratorSpec se pasa a la KeyPairGenerator que se inicializa con KEY_ALGORITHM_RSA y el "AndroidKeyStore" proveedor.

private void testPreMEncryption () try // Genere un par de llaves y almacénelo en el KeyStore KeyStore keyStore = KeyStore.getInstance ("AndroidKeyStore"); keyStore.load (null); Inicio del calendario = Calendar.getInstance (); Fin del calendario = Calendar.getInstance (); end.add (Calendar.YEAR, 10); KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder (this) .setAlias ​​("MyKeyAlias") .setSubject (new X500Principal ("CN = MyKeyName, O = Android Authority")) .setSerialNumber (new BigInteger (1024, new Random ()). setStartDate (start.getTime ()) .setEndDate (end.getTime ()) .setEncryptionRequired () // en el nivel API 18, cifrado en reposo, requiere que se configure la pantalla de bloqueo, cambiar la pantalla de bloqueo elimina la clave .build (); KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance (KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore"); keyPairGenerator.initialize (spec); keyPairGenerator.generateKeyPair (); // Byte final de prueba de cifrado [] encryptedBytes = rsaEncrypt ("Mi cadena secreta!". GetBytes ("UTF-8")); byte final [] decryptedBytes = rsaDecrypt (encryptedBytes); String final decryptedString = new String (decryptedBytes, "UTF-8"); Log.e ("MyApp", "La cadena desencriptada es" + decryptedString);  catch (Throwable e) e.printStackTrace (); 

Para encriptar, obtenemos la RSAPublicKey desde el par de llaves y úsalo con el Cifrar objeto. 

byte público [] rsaEncrypt (byte final [] decryptedBytes) byte [] encryptedBytes = null; try final KeyStore keyStore = KeyStore.getInstance ("AndroidKeyStore"); keyStore.load (null); KeyStore.PrivateKeyEntry final privateKeyEntry = (KeyStore.PrivateKeyEntry) keyStore.getEntry ("MyKeyAlias", null); final RSAPublicKey publicKey = (RSAPublicKey) privateKeyEntry.getCertificate (). getPublicKey (); Cipher final cipher = Cipher.getInstance ("RSA / ECB / PKCS1Padding", "AndroidOpenSSL"); cipher.init (Cipher.ENCRYPT_MODE, publicKey); ByteArrayOutputStream outputStream final = new ByteArrayOutputStream (); CipherOutputStream final cipherOutputStream = new CipherOutputStream (outputStream, cipher); cipherOutputStream.write (decryptedBytes); cipherOutputStream.close (); encryptedBytes = outputStream.toByteArray ();  catch (Throwable e) e.printStackTrace ();  return encryptedBytes; 

El descifrado se realiza mediante el RSAPrivateKey objeto.

public byte [] rsaDecrypt (byte final [] encryptedBytes) byte [] decryptedBytes = null; try final KeyStore keyStore = KeyStore.getInstance ("AndroidKeyStore"); keyStore.load (null); KeyStore.PrivateKeyEntry final privateKeyEntry = (KeyStore.PrivateKeyEntry) keyStore.getEntry ("MyKeyAlias", null); final RSAPrivateKey privateKey = (RSAPrivateKey) privateKeyEntry.getPrivateKey (); Cipher final cipher = Cipher.getInstance ("RSA / ECB / PKCS1Padding", "AndroidOpenSSL"); cipher.init (Cipher.DECRYPT_MODE, privateKey); CipherInputStream final cipherInputStream = new CipherInputStream (new ByteArrayInputStream (encryptedBytes), cifrado); ArrayList final arrayList = new ArrayList <> (); int nextByte; while ((nextByte = cipherInputStream.read ())! = -1) arrayList.add ((byte) nextByte);  decryptedBytes = nuevo byte [arrayList.size ()]; para (int i = 0; i < decryptedBytes.length; i++)  decryptedBytes[i] = arrayList.get(i);   catch (Throwable e)  e.printStackTrace();  return decryptedBytes; 

Una cosa acerca de RSA es que el cifrado es más lento que en AES. Por lo general, esto está bien para pequeñas cantidades de información, como cuando está asegurando cadenas de preferencias compartidas. Sin embargo, si encuentra un problema de rendimiento en el cifrado de grandes cantidades de datos, puede utilizar este ejemplo para cifrar y almacenar solo una clave AES. Luego, use el cifrado AES más rápido que se trató en el tutorial anterior para el resto de sus datos. Puede generar una nueva clave AES y convertirla en un byte[] matriz que es compatible con este ejemplo.

KeyGenerator keyGenerator = KeyGenerator.getInstance ("AES"); keyGenerator.init (256); // AES-256 SecretKey secretKey = keyGenerator.generateKey (); byte [] keyBytes = secretKey.getEncoded ();

Para recuperar la clave de los bytes, haga esto:

Clave SecretKey = nueva SecretKeySpec (keyBytes, 0, keyBytes.length, "AES");

¡Eso fue un montón de código! Para mantener todos los ejemplos simples, he omitido el manejo completo de excepciones. Pero recuerde que para su código de producción, no se recomienda simplemente capturar todos Tirable casos en una declaración de captura.

Conclusión

Esto completa el tutorial sobre cómo trabajar con credenciales y claves. Gran parte de la confusión en torno a las claves y el almacenamiento tiene que ver con la evolución del sistema operativo Android, pero puede elegir qué solución usar dado el nivel de API que su aplicación admite. 

Ahora que hemos cubierto las mejores prácticas para asegurar los datos en reposo, el próximo tutorial se centrará en asegurar los datos en tránsito.