Hola% username%. Independientemente del tema del informe, constantemente me preguntan en las conferencias la misma pregunta: "¿cómo almacenar de forma segura los tokens en el dispositivo del usuario?". Por lo general, trato de responder, pero el tiempo no permite revelar completamente el tema. Con este artículo quiero cerrar completamente este problema.
Analicé una docena de aplicaciones para ver cómo funcionan con tokens. Todas las aplicaciones que analicé procesaron datos críticos y me permitieron establecer un código pin para la entrada como protección adicional. Veamos los errores más comunes:
- Enviar un código PIN a la API junto con RefreshToken para confirmar la autenticación y recibir nuevos tokens. - Malo, RefreshToken es inseguro en el almacenamiento local, con acceso físico al dispositivo o copia de seguridad, puede eliminarlo, así como el malware puede hacerlo.
- Guardando el código pin en el mensaje con RefreshToken, luego la verificación local del código pin y enviando el RefreshToken a la API. - Una pesadilla, RefreshToken es inseguro junto con el pin, lo que permite que se extraigan, además, aparece otro vector que sugiere omitir la autenticación local.
- Cifrado incorrecto de RefreshToken con un código pin, que le permite restaurar el código pin y RefreshToken desde el texto cifrado. - Un caso especial de un error anterior, explotado un poco más complicado. Pero tenga en cuenta que esta es la forma correcta.
Después de ver los errores comunes, puede proceder a pensar en la lógica del almacenamiento seguro de tokens en su aplicación. Vale la pena comenzar con los activos básicos asociados con la autenticación / autorización durante el funcionamiento de la aplicación y presentar algunos requisitos para ellos:
Las credenciales (nombre de usuario + contraseña) se utilizan para autenticar al usuario en el sistema.
+ la contraseña nunca se almacena en el dispositivo y debe borrarse inmediatamente de la RAM después de enviarla a la API
+ no se transmiten por el método GET en los parámetros de consulta de la solicitud HTTP, en su lugar se utilizan solicitudes POST
+ la memoria caché del teclado está deshabilitada para el procesamiento de contraseña de campos de texto
+ el portapapeles está desactivado para los campos de texto que contienen una contraseña
+ la contraseña no se revela a través de la interfaz de usuario (usan asteriscos), además, la contraseña no entra en capturas de pantalla
AccessToken : se utiliza para confirmar la autorización del usuario.
+ nunca almacenado en la memoria a largo plazo y almacenado solo en la RAM
+ no se transmiten por el método GET en los parámetros de consulta de la solicitud HTTP, en su lugar se utilizan solicitudes POST
RefreshToken : se utiliza para obtener un nuevo paquete AccessToken + RefreshToken.
+ no se almacena de ninguna forma en la RAM y debe eliminarse inmediatamente después de recibir de la API y guardar en la memoria a largo plazo o después de recibir de la memoria a largo plazo y usar
+ almacenado solo en forma cifrada en la memoria a largo plazo
+ encriptado con un pin usando magia y ciertas reglas (las reglas se describirán a continuación), aquellas que si el pin no se ha establecido, entonces no guarde nada
+ no se transmiten por el método GET en los parámetros de consulta de la solicitud HTTP, en su lugar se utilizan solicitudes POST
PIN : (generalmente un número de 4 o 6 dígitos): se usa para cifrar / descifrar RefreshToken.
+ nunca se almacena en ningún lugar del dispositivo y debe eliminarse inmediatamente de la RAM después del uso
+ nunca sale de los límites de la aplicación, esos no se transmiten a ningún lado
+ usado solo para cifrado / descifrado RefreshToken
OTP es un código de una sola vez para 2FA.
+ OTP nunca se almacena en el dispositivo y debe borrarse inmediatamente de la RAM después de enviarlo a la API
+ no se transmiten por el método GET en los parámetros de consulta de la solicitud HTTP, en su lugar se utilizan solicitudes POST
+ caché de teclado desactivado para campos de texto que procesan OTP
+ portapapeles desactivado para campos de texto que contienen OTP
+ OTP no entra en capturas de pantalla
+ la aplicación elimina OTP de la pantalla cuando pasa al fondo
Ahora pasemos a la
magia de la criptografía. El requisito principal es que en ningún caso debe permitir la implementación de dicho mecanismo de cifrado RefreshToken, en el que puede validar el resultado de descifrado localmente. Es decir, si un atacante tomara posesión del texto cifrado, no debería poder recoger la llave. El único validador debería ser la API. Esta es la única forma de limitar los intentos de selección de teclas e invalidar las fichas en caso de un ataque de Fuerza Bruta.
Daré un buen ejemplo, digamos que queremos encriptar el UUID
aec27f0f-b8a3-43cb-b076-e075a095abfe
con este conjunto de AES / CBC / PKCS5Padding, utilizando un PIN como clave. Parece que el algoritmo es bueno, todo se basa en pautas, pero hay un punto clave: la clave contiene muy poca entropía. Veamos a qué conduce esto:
- Relleno: dado que nuestro token ocupa 36 bytes y AES es un modo de cifrado de bloque con un bloque de 128 bits, entonces el algoritmo debe terminar el token de hasta 48 bytes (que es un múltiplo de 128 bits). En nuestra versión, la cola se agregará de acuerdo con el estándar PKCS5Padding, es decir el valor de cada byte agregado es igual al número de bytes agregados
01
02 02
03 03 03
04 04 04 04
05 05 05 05 05
06 06 06 06 06 06
etc.
Nuestro último bloque se verá así:
... | 61 62 66 65 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C |
Y hay un problema, al observar este relleno, podemos filtrar los datos (por el último bloque no válido) descifrados por la clave incorrecta y, por lo tanto, determinar el RefreshToken válido del montón retorcido. - Formato predecible del token: incluso si hacemos nuestro token un múltiplo de 128 bits (por ejemplo, eliminando guiones) para evitar agregar relleno, nos encontraremos con el siguiente problema. El problema es que del mismo montón retorcido podemos recopilar las líneas y determinar cuál cae en el formato UUID. El UUID en su forma textual canónica tiene 32 dígitos en formato hexadecimal separados por un guión en 5 grupos 8-4-4-4-12
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
donde M es la versión y N es la opción. Todo esto es suficiente para filtrar los tokens descifrados con la clave incorrecta, dejando un formato UUID RefreshToken adecuado.
Dado todo lo anterior, puede continuar con la implementación, elegí una opción simple para generar 64 bytes aleatorios y envolverlos en base64:
public String createRefreshToken() { byte[] refreshToken = new byte[64]; final SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(refreshToken); return Base64.getUrlEncoder().withoutPadding() .encodeToString(refreshToken); }
Aquí hay un ejemplo de tal token:
YmI8rF9pwB1KjJAZKY9JzqsCu3kFz4xt4GkRCzXS9-FS_kbN3-CF9RGiRuuGqwqMo-VxFDhgQNmgjlQFD2GvbA
Ahora veamos cómo se ve algorítmicamente (en Android e iOS el algoritmo será el mismo):
private static final String ALGORITHM = "AES"; private static final String CIPHER_SUITE = "AES/CBC/NoPadding"; private static final int AES_KEY_SIZE = 16; private static final int AES_BLOCK_SIZE = 16; public String encryptToken(String token, String pin) { decodedToken = decodeToken(token);
A qué líneas vale la pena prestar atención:
private static final String CIPHER_SUITE = "AES/CBC/NoPadding";
Sin relleno, bueno, te acuerdas.
decodedToken = decodeToken(token);
No puede simplemente tomar y cifrar un token en la representación de base64, ya que esta representación tiene un cierto formato (bueno, recuerda).
byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE);
En la salida, obtenemos una clave de tamaño AES_KEY_SIZE, adecuada para el algoritmo AES. Cualquier función de derivación de teclas recomendada por Argon2, SHA-3, Scrypt puede usarse como kdf en caso de mala vida pbkdf2 (es muy similar en FPGA).
El token cifrado final se puede almacenar de forma segura en el dispositivo y no te preocupes de que alguien pueda robarlo, ya sea un malware o una entidad no agobiada por principios morales.
Algunas recomendaciones más:
- Excluir tokens de las copias de seguridad.
- En iOS, almacene el token en llavero con el atributo kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly.
- No esparza los activos discutidos en este artículo (clave, pin, contraseña, etc.) en toda la aplicación.
- Sobrescriba los activos tan pronto como sean innecesarios, no los guarde en su memoria más tiempo del necesario.
- Use SecureRandom en Android y SecRandomCopyBytes en iOS para generar bytes aleatorios en un contexto criptográfico.
Examinamos un cierto número de dificultades al almacenar tokens, que, en mi opinión, deberían ser conocidos por todas las personas que desarrollan aplicaciones que funcionan con datos críticos. Este tema, en el que puede confundirse en cualquier paso, si tiene preguntas, hágalas en los comentarios. Los comentarios sobre el texto también son bienvenidos.
Referencias
CWE-311: Falta el cifrado de datos confidencialesCWE-327: uso de un algoritmo criptográfico roto o arriesgadoCWE-327: CWE-338: Uso del generador de números pseudoaleatorios criptográficamente débiles (PRNG)CWE-598: Exposición de información a través de cadenas de consulta en solicitud GET