React Native Security Guide

Isaías Chávez
May 2024
12 min read

Summary

Escribí esta guía para aportar algo de realismo a la seguridad en React Native. Mucha gente confía en paquetes "seguros" sin entender realmente qué se puede y qué no se puede proteger en un dispositivo móvil. Aquí presento una regla simple, hablo honestamente sobre secrets, datos de usuario, gestión de claves, almacenamiento respaldado por hardware y cifrado, y muestro patrones que considero aceptables en producción. No se trata de balas mágicas, se trata de conocer tu modelo de amenazas y usar las herramientas que tenemos de manera que los ataques sean costosos e imprácticos en la vida real.

Al igual que con cualquier otro framework, la seguridad debería estar cerca del principio de la lista de prioridades al construir apps de React Native. El problema es que la conversación está llena de medias verdades: paquetes brillantes, marketing vago y muy poca información que sea realmente específica para móvil y React Native.

En esta guía recorro las preocupaciones de seguridad que creo que todo desarrollador de React Native debería al menos entender. Si encuentras algo incorrecto o incompleto, por favor contáctame; este es el tipo de tema que se beneficia de más ojos y más matices.

Prefacio

Empecemos con la parte incómoda: una vez que los datos están almacenados en el dispositivo, no existe tal cosa como protección al 100%. En tiempo de ejecución el OS puede ayudar con protecciones de memoria y controles de acceso, pero un atacante motivado con un dispositivo rooteado o con jailbreak y acceso físico eventualmente evadirá esas capas. Este artículo de 1Password explica esa realidad muy bien.

Así que mi regla base es simple: no coloques nada en el dispositivo que absolutamente no puedas permitirte que se filtre.

Eso no significa que nos rendimos. Si el atacante no controla el hardware o el OS, y es "solo" otra app o un script-kiddie curioseando, las protecciones que agregamos todavía importan. Una buena práctica de seguridad consiste en hacer que los ataques sean dolorosos, consuman tiempo y sean ruidosos.

Secrets

Con eso en mente, aquí está la dura verdad: no podrás proteger completamente secrets como claves API de terceros dentro de una app móvil. No importa cómo los ocultes, ofusques o envuelvas. La misma lógica aplica a las claves de cifrado: si la clave vive en el dispositivo, y el dispositivo está completamente comprometido, los datos cifrados eventualmente serán legibles.

La diferencia es la propiedad. Los datos del usuario le pertenecen al usuario; si hackean su propio teléfono, solo están viendo información a la que ya tenían derecho. Los secrets compartidos son diferentes; una vez que un dispositivo está comprometido, esos secrets pueden ser abusados para todos los usuarios.

¿Podemos hacer los secrets más seguros? A veces. Si controlas el backend, puedes mover algo de lógica detrás de un gateway. Pero cuando dependes de SDKs de terceros que esperan la clave directamente en la app, solo hay tanto que puedes hacer.

Device Attestation

Una herramienta que a menudo se ignora es la attestation del dispositivo. Aquí el OS intenta demostrar que el dispositivo y/o app no ha sido alterado. Tanto iOS como Android ofrecen APIs para esto, y hay vendors que envuelven esto en SDKs.

Con attestation, puedes construir algo como esto:

  1. Al iniciar la app, realiza attestation. Si falla, niega el acceso y detente ahí mismo.
  2. En la primera ejecución exitosa, obtén los secrets y almacénalos usando un mecanismo de almacenamiento seguro.
  3. Opcionalmente divide las claves para que parte del material deba ser obtenido del servidor cada vez.

Un atacante determinado todavía puede ir tras el OS mismo, pero has elevado la barrera considerablemente.

Paquetes Env

También quiero mencionar paquetes como react-native-dotenv y react-native-config. Son útiles para configuración pero peligrosos si desarrollas un falso sentido de seguridad a su alrededor. Todavía terminan empaquetando tus "env vars" en la app y leyéndolas en tiempo de ejecución. No convierten mágicamente tus secrets en variables de entorno del lado del servidor.

En entornos Docker o servidor, las env vars viven en memoria, no se persisten, y están detrás de un límite de red. En móvil, tu bundle se envía al atacante. Es un modelo de amenazas completamente diferente, sin importar el lenguaje o framework.

Datos de Usuario

Entonces, ¿por qué molestarse en cifrar datos de usuario? Porque en el mundo real, no todos los atacantes tienen control total del dispositivo. Proteger datos contra otras apps, backups, inspección casual o teléfonos robados rápidamente todavía vale la pena.

Muchos paquetes de React Native afirman cifrar tus datos. Algunos son sólidos; otros reinventan la criptografía mal o dependen de primitivas obsoletas. En lugar de auditar cada librería aquí, describiré un flujo ideal que, si se implementa correctamente, debería ser razonablemente seguro en dispositivos sin alterar.

Genera y almacena de forma segura tu clave de cifrado

Un patrón común en la documentación se ve así:

const myKey = "password_is_password";

const storage = MyStateLibrary.create({
  encryptionKey: myKey,
});

Esa clave vivirá en tu bundle para siempre. Cualquiera puede decompilar la app, leerla y descifrar todo. En React Native es aún más fácil: descomprime el APK/IPA e inspecciona el bundle de JS.

Un mejor patrón es generar una clave en tiempo de ejecución y almacenarla usando almacenamiento seguro a nivel del OS. Usaré op-s2 aquí, pero puedes hacer lo mismo con Expo Secure Store o react-native-keychain.

import { set } from "@op-engineering/op-s2";
import { generateSecureRandom } from "react-native-securerandom";

async function generateKey() {
  // generate secure bytes using SecRandomCopyBytes on iOS and SecureRandom on Android
  const secureBytes = await generateSecureRandom(42);

  // on modern RN Hermes, btoa is available
  const key = btoa(String.fromCharCode.apply(null, secureBytes));

  const { error } = set({
    key: "myKey",
    value: key,
    withBiometrics: true, // FaceID/biometrics prompt every time; see docs if you want a different UX
  });
}

withBiometrics es la opción más estricta, pero puedes omitirla y aún obtener buena protección. Keychain/Keystore refuerzan el acceso por app; otras apps no pueden simplemente leer tus valores almacenados.

Más adelante, cuando inicialices tu almacenamiento:

const myKey = get({ key: "myKey", withBiometrics: true });

const storage = MyStateLibrary.create({
  encryptionKey: myKey,
});

const myKey = null;
// Optionally trigger GC to make time-based attacks harder

No es inquebrantable, pero es mucho mejor que hardcodear una clave. Limpiar valores sensibles de la memoria es simplemente buena higiene.

En iOS, Keychain es esencialmente una base de datos SQLite con protecciones a nivel del OS en capas superiores.

En Android, Keystore gestiona las claves; los datos cifrados usualmente se almacenan vía EncryptedSharedPreferences o APIs similares.

Usa Almacenamiento Seguro

Una vez que tienes una clave, puedes empezar a usarla en implementaciones de almacenamiento reales. Algunas librerías ya soportan esto directamente. Por ejemplo, MMKV permite especificar una clave de cifrado:

import { get } from "@op-engineering/op-s2";
import { MMKV, Mode } from "react-native-mmkv";

const myKey = get({ key: "myKey", withBiometrics: true });

export const storage = new MMKV({
  id: `user-${userId}-storage`,
  path: `${USER_DIRECTORY}/storage`,
  encryptionKey: myKey,
  mode: Mode.MULTI_PROCESS,
});

Otra opción es el cifrado completo de base de datos vía SQLCipher, expuesto en React Native con op-sqlite. Lo habilitas en package.json:

"op-sqlite": {
  "sqlcipher": true
}

Y luego:

import { open } from '@op-engineering/op-sqlite';

const myKey = get({ key: "myKey", withBiometrics: true });

const db = open({
  name: 'my_secure_db.sqlite',
  encryptionKey: myKey
});

Con esta configuración, todo en disco está cifrado. Mientras tu clave permanezca segura, tus datos tienen una capa fuerte de protección en dispositivos sin alterar.

Hardware Keys

Las hardware keys evitan muchas amenazas en el dispositivo. Porque viven fuera del OS, los atacantes remotos no pueden comprometerlas fácilmente (el robo físico es una historia diferente). Agregan otra capa además de lo que el teléfono puede hacer.

Por ejemplo, YubiKey puede usarse para autenticación fuerte y como fuente de bytes aleatorios seguros para claves de cifrado. No puedes almacenar datos arbitrarios en ellas, pero aún puedes basar el material de claves en operaciones respaldadas por hardware.

Si alguien alguna vez quiere patrocinar un TurboModule apropiado alrededor de esta idea, estaría feliz de ayudar a hacerlo disponible para apps de React Native.

Algoritmos de cifrado

Si necesitas primitivas criptográficas o quieres construir algo personalizado, usa react-native-quick-crypto. Refleja la API crypto de Node.js, es rápida gracias a bindings de C++, y se basa en patrones probados en batalla en lugar de cifrado ad-hoc.

También vale la pena verificar con la documentación oficial de React Native.