todos los sistemas operativostodo operativo

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.10

~/blog/flutter-secure-storage-bad-decrypt-android.md

Cuando el almacenamiento seguro te bloquea el login: BAD_DECRYPT en Flutter y Android

Un login que funciona en casi todos los teléfonos pero falla siempre en unos pocos Android, aunque la clave esté bien. La culpa no era la red ni el servidor: era el cifrado del almacenamiento seguro. Qué pasa, cómo diagnosticarlo y cómo corregirlo sin perder sesiones.

autor
Alejandro Peñaloza
Publicado
Lectura
5 min
Portada de BAD_DECRYPT: candado azul agrietado con una fisura roja junto al texto "cuando el almacenamiento seguro te bloquea el login" y las etiquetas Flutter, Android y flutter_secure_storage
En este artículo
  1. El primer error: adivinar en vez de medir
  2. Cómo funciona flutter_secure_storage en Android
  3. Otros fallos típicos del almacenamiento seguro en Android
  4. La solución
  5. Lecciones

Hay bugs que se esconden muy bien. Este fue uno de ellos: algunos usuarios de una app Flutter no podían iniciar sesión en Android. Les salía siempre un genérico "No pudimos iniciar sesión, intenta más tarde". Mientras tanto, en mis teléfonos de prueba (dos Android y un iPhone) todo funcionaba perfecto.

Las pistas parecían contradictorias:

  • La contraseña era correcta.
  • "Borrar datos" de la app no lo arreglaba.
  • Desinstalar y reinstalar desde la tienda tampoco.
  • Fallaba siempre, no de vez en cuando.

El primer error: adivinar en vez de medir

Mi primera sospecha fue el almacenamiento seguro. La descarté rápido porque "si borrar datos no lo arregla, no puede ser el storage". Me fui por otro lado: errores de red, TLS, timeouts, respuestas raras del servidor. Blindé todo eso (reintentos, mejores mensajes, manejo de excepciones que antes se escapaban). Todo útil, pero ninguno era el problema.

Lo que de verdad destrabó el caso fue agregar un log de diagnóstico que el usuario pudiera compartir. Es un registro en memoria, sin datos sensibles, con cada paso del login y cada request: método, path, status y tiempo. El usuario lo envía por WhatsApp o correo con un botón que solo aparece ante errores técnicos (nunca por clave incorrecta).

El primer log que llegó lo dejó todo claro:

[splash] PlatformException: ... javax.crypto.BadPaddingException:
         error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT
[login]  inicio
[http]   POST /oauth/token -> 200 (1046 ms)
[http]   ERROR PlatformException: ... BadPaddingException: BAD_DECRYPT (17 ms)
[login]  error

El servidor respondía 200: la clave estaba bien. La app guardaba el token sin error. Pero en el siguiente request, al leer ese mismo token recién guardado, el descifrado fallaba. Y como pasaba con datos recién creados, borrar datos no cambiaba nada. El problema no estaba en los datos, estaba en el cifrado.

Cómo funciona flutter_secure_storage en Android

En iOS, flutter_secure_storage usa el Keychain y casi no da sorpresas. En Android la historia es otra: el plugin cifra los valores y los guarda en SharedPreferences, y la llave vive (directa o indirectamente) en el Android Keystore.

En las versiones 8.x el esquema por defecto era:

Pieza Algoritmo por defecto (v8)
Cifrado de la llave (key cipher) RSA/ECB/PKCS1Padding
Cifrado de los datos (storage cipher) AES/CBC/PKCS7Padding

Es decir: una llave AES cifra tus datos, esa llave AES se guarda envuelta con una llave RSA del Keystore, y en cada operación se desenvuelve y se usa. Funciona en la gran mayoría de los equipos. Pero depende de cómo cada fabricante implementa el Keystore y los proveedores criptográficos. Si en algún equipo esa cadena no se comporta igual al escribir que al leer, obtienes exactamente esto: escribe bien, lee mal, BAD_DECRYPT.

flowchart LR
  A[Valor] -->|AES-CBC| B[Texto cifrado en SharedPreferences]
  K[Llave AES] -->|envuelta con RSA PKCS1| S[SharedPreferences]
  R[Llave RSA] --- KS[(Android Keystore)]
  S -->|desenvolver| K
  B -->|descifrar| X{¿OK?}
  X -->|no| E[BadPaddingException / BAD_DECRYPT]

Otros fallos típicos del almacenamiento seguro en Android

Además del caso anterior, hay otros escenarios conocidos que terminan en el mismo síntoma ("no puedo entrar" o "la app me saca"):

  1. Backup y restauración. Con allowBackup activo, Android puede respaldar SharedPreferences en la nube y restaurarlas en otro equipo o tras reinstalar. Pero las llaves del Keystore no viajan: quedan datos cifrados con una llave que ya no existe. Ese storage es ilegible para siempre.
  2. Cambios de seguridad del dispositivo. Quitar o cambiar el PIN o patrón, o una actualización del sistema, puede invalidar llaves del Keystore en algunos equipos.
  3. Implementaciones de fabricante. Capas como las de algunos fabricantes, o versiones nuevas de Android, a veces cambian el comportamiento de algoritmos antiguos (como RSA con PKCS1).
  4. Errores que se tragan. Si tu código hace catch (e) y muestra un mensaje genérico, nunca sabrás que fue una PlatformException del storage y no un problema de red.

La solución

1. Actualizar a flutter_secure_storage 10

La versión 10 reescribió la implementación de Android:

  • Cambia los algoritmos por defecto a RSA/ECB/OAEPwithSHA-256andMGF1Padding para la llave y AES/GCM/NoPadding para los datos.
  • Con migrateOnAlgorithmChange (activo por defecto) migra solo los datos guardados con los algoritmos antiguos, así tus usuarios no pierden la sesión al actualizar.
  • resetOnError viene activo por defecto: si el storage queda ilegible, lo reinicia en vez de dejar la app trabada.
dependencies:
  flutter_secure_storage: ^10.3.4

Ojo con saltar directo a la v11. La 11 elimina los algoritmos antiguos (RSA_ECB_PKCS1Padding, AES_CBC_PKCS7Padding) y ya no puede leer datos de la 8.x. Si pasas de 8 a 11 sin pasar por la 10, todos tus usuarios pierden la sesión. Primero publica la 10, deja que migre, y recién después sube a la 11.

2. Probar el storage al arrancar: escribir y leer

Una prueba de salud al iniciar la app ayuda mucho. Pero aprendí que escribir no basta: en el caso real, la escritura funcionaba y lo que fallaba era la lectura. La prueba tiene que ser de ida y vuelta:

static Future<void> repararSiIlegible() async {
  const probe = '_storage_probe';
  try {
    await storage.write(key: probe, value: '1');
    if (await storage.read(key: probe) != '1') {
      throw StateError('lectura no coincide con lo escrito');
    }
    await storage.delete(key: probe);
  } catch (e) {
    // Storage ilegible: no hay sesión que perder, se reinicia.
    log('storage ilegible: $e');
    try {
      await storage.deleteAll();
    } catch (_) {}
  }
}

3. Desactivar el backup de datos cifrados

Si tus datos cifrados dependen del Keystore, no tiene sentido respaldarlos:

<application
    android:allowBackup="false"
    android:fullBackupContent="false"
    android:dataExtractionRules="@xml/data_extraction_rules">

Con data_extraction_rules.xml excluyendo sharedpref (y lo demás que corresponda) de cloud-backup y device-transfer.

4. Logs compartibles y sin datos sensibles

Este fue el verdadero héroe. Algunas reglas que me funcionaron:

  • Enmascarar antes de guardar, no al exportar: tokens, cookies, contraseñas, correos, identificadores personales y query strings.
  • Guardar solo el path de las URLs, sin parámetros.
  • Incluir datos del equipo que sirvan para diagnosticar: versión de Android y SDK, fabricante y modelo, parche de seguridad, idioma y zona horaria. Nada que identifique a la persona.
  • Mostrar el botón de compartir solo en errores técnicos. Una clave incorrecta no es un bug.

Lecciones

  • "Borrar datos no lo arregla" no descarta el storage. Solo descarta que los datos guardados estén corruptos. Si el cifrado falla con datos nuevos, el problema es el algoritmo o el Keystore del equipo.
  • Escribir no prueba que puedas leer. Las pruebas de salud tienen que hacer el ciclo completo.
  • Que funcione en tus teléfonos no prueba nada cuando el problema depende del fabricante y de la versión de Android.
  • Sin telemetría estás adivinando. Un log pequeño, en memoria y sanitizado, que el usuario pueda compartir con un toque, vale más que horas de hipótesis.
  • Las migraciones de cifrado se hacen por etapas. Saltarse una versión mayor puede borrar la sesión de todos tus usuarios de un plumazo.

compartir:linkedinx

Comentarios

Aún no hay comentarios. ¿Quieres dejar el primero?

Deja un comentario

Los comentarios se revisan antes de publicarse. No pido correo ni guardo tu IP.

$ ls ~/blog | grep relacionados

Sigue leyendo