Keychain: guardar secretos de verdad
El llavero del sistema por dentro: el API de consultas como diccionarios, las clases de accesibilidad y su semantica exacta, el control de acceso con biometria, y el argumento tecnico de por que las preferencias nunca son un sitio para un token.
Hay una categoría de datos que no admite el criterio de comodidad: tokens de sesión, contraseñas, claves de cifrado, secretos de un segundo factor. Para ellos el sistema ofrece un almacén aparte, con cifrado respaldado por hardware y una política explícita sobre cuándo está dispuesto a descifrar. Su API es incómodo, heredado de C y lleno de constantes con prefijo, y esa incomodidad ha empujado a generaciones de apps a guardar credenciales donde no debían.
- Describir qué es el llavero, dónde vive y por qué sobrevive a la app.
- Manejar el API de consultas como diccionarios de atributos, con sus cuatro operaciones.
- Elegir la clase de accesibilidad correcta y anticipar su efecto en segundo plano.
- Argumentar técnicamente por qué
UserDefaultsno es una alternativa aceptable.
Qué es realmente el llavero
El Keychain no es un archivo de tu app: es una base de datos del sistema, compartida y protegida, a la que accedes mediante un servicio con permisos. Cada elemento se guarda cifrado con claves derivadas del hardware del dispositivo y, según la clase que elijas, también del código de acceso del usuario. El sistema decide si descifra en función del estado del dispositivo, no de lo que tu proceso pida.
Ese diseño tiene tres consecuencias prácticas. La primera es que un elemento del llavero puede sobrevivir a la desinstalación de la app, comportamiento que sorprende y que conviene manejar borrando de forma explícita en el primer arranque tras una instalación limpia. La segunda es que varias apps del mismo equipo pueden compartir elementos si declaran un grupo de acceso común. Y la tercera es que cada operación cruza al servicio del sistema, así que el coste por llamada es órdenes de magnitud mayor que leer una preferencia: se lee una vez y se guarda en memoria durante la sesión.
Cifrado por hardware
Las claves derivan del enclave seguro. Sin el dispositivo no hay descifrado posible.
Atributos
Cada elemento es un conjunto de atributos consultables, no solo un par de clave y valor.
Grupo de acceso
Apps del mismo equipo pueden compartir elementos declarando un grupo común.
Supervivencia
Los elementos persisten tras desinstalar. Limpia en el primer arranque si no lo quieres.
El API: consultas como diccionarios
Todo en el llavero es una consulta: un diccionario de atributos que describe qué buscas o qué añades. Hay cuatro operaciones — añadir, copiar, actualizar y borrar — y todas devuelven un código de estado en lugar de lanzar. La clase más habitual para un token es la de contraseña genérica, identificada por un servicio y una cuenta.
import Security
enum LlaveroError: Error { case estado(OSStatus) }
func guardarSecreto(_ dato: Data, servicio: String, cuenta: String) throws {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: servicio,
kSecAttrAccount as String: cuenta
]
let atributos: [String: Any] = [
kSecValueData as String: dato,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let estado = SecItemUpdate(base as CFDictionary, atributos as CFDictionary)
if estado == errSecItemNotFound {
var nuevo = base
nuevo.merge(atributos) { _, nuevoValor in nuevoValor }
let alta = SecItemAdd(nuevo as CFDictionary, nil)
guard alta == errSecSuccess else { throw LlaveroError.estado(alta) }
} else if estado != errSecSuccess {
throw LlaveroError.estado(estado)
}
}
func leerSecreto(servicio: String, cuenta: String) throws -> Data? {
let consulta: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: servicio,
kSecAttrAccount as String: cuenta,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var resultado: CFTypeRef?
let estado = SecItemCopyMatching(consulta as CFDictionary, &resultado)
if estado == errSecItemNotFound { return nil }
guard estado == errSecSuccess else { throw LlaveroError.estado(estado) }
return resultado as? Data
}
El patrón de actualizar primero y añadir si no existe evita el error clásico de duplicado, que ocurre porque el llavero considera único el conjunto de atributos primarios y rechaza un alta que colisione. Envuelve estas funciones en un tipo pequeño una sola vez y no vuelvas a escribir un diccionario de estos en tu código de aplicación.
Accesibilidad: cuándo el sistema descifra
La clase de accesibilidad es la decisión de seguridad de verdad, y es la que casi nadie razona. Define bajo qué estado del dispositivo el sistema está dispuesto a entregar el dato descifrado, y su elección tiene efectos funcionales inmediatos.
La clase de desbloqueo exige que el dispositivo esté desbloqueado en ese instante: máxima protección y garantía de que una tarea en segundo plano con la pantalla apagada fallará al leer. La clase de primer desbloqueo permite el acceso desde el primer desbloqueo tras el arranque hasta el siguiente reinicio, que es lo que necesita cualquier sincronización en segundo plano. El sufijo que restringe al dispositivo impide que el elemento viaje en una copia de seguridad o a otro dispositivo, y es lo correcto para tokens que solo tienen sentido aquí.
flowchart TD A[Necesito leer el secreto en segundo plano] -->|no| B[Clase de desbloqueo] A -->|si| C[Clase de primer desbloqueo] B --> D[Debe viajar a otro dispositivo] C --> D D -->|no| E[Anadir sufijo solo este dispositivo] D -->|si| F[Dejar que el llavero lo propague] style E fill:#a6e3a1,color:#11111b
Por encima de la clase existe el control de acceso, que añade una condición de presencia del usuario: el sistema solo entrega el dato tras una autenticación biométrica o con el código. Es la herramienta correcta para desbloquear una clave maestra, y hay que declararla al crear el elemento, no al leerlo.
var error: Unmanaged<CFError>?
let control = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
&error
)
La bandera que exige el conjunto biométrico actual hace que el elemento quede inutilizable si el usuario añade o elimina una huella o un rostro. Es exactamente lo que quieres para una clave maestra, y exactamente lo que no quieres para un token de sesión que entonces obligaría a reautenticar sin motivo.
Por qué no vale UserDefaults
El argumento no es estilístico. Las preferencias se serializan a una lista de propiedades sin cifrar dentro del contenedor; el llavero se cifra con claves que no salen del enclave seguro. Las preferencias entran completas en la copia de seguridad, y si esa copia no está cifrada el secreto viaja en claro; los elementos del llavero marcados como propios del dispositivo no salen nunca. Las preferencias se cargan enteras en memoria del proceso y quedan expuestas a cualquier volcado; el llavero entrega el dato solo cuando el estado del dispositivo lo permite.
Existe además un matiz que suele decidir discusiones de equipo. Cifrar el secreto tú mismo y guardar el resultado en preferencias no resuelve nada, porque desplaza el problema a dónde guardas la clave de cifrado, y esa clave acaba en el binario o en el mismo archivo que protege. El llavero es exactamente el eslabón que rompe esa recursión: es el único sitio del sistema donde una clave puede reposar sin necesitar otra clave por encima.
// Antipatron: la clave vive junto al dato que protege
let cifrado = cifrar(token, con: claveEmbebida)
UserDefaults.standard.set(cifrado, forKey: "token")
// Correcto: el sistema custodia el secreto y su politica de acceso
try guardarSecreto(token, servicio: "api", cuenta: "sesion")
El único uso legítimo de las preferencias en esta historia es guardar metadatos no sensibles del secreto — cuándo caduca, qué cuenta lo posee — para evitar consultar el llavero en cada arranque.
Merece la pena entender por qué tantas apps guardan tokens donde no deben, porque la explicación no es la ignorancia. Guardar un secreto en preferencias es una línea; hacerlo en el llavero exige diccionarios de constantes puenteadas desde C, códigos de estado que hay que traducir, un patrón de alta contra actualización que nadie te explica y una decisión de accesibilidad cuyos efectos solo se manifiestan en segundo plano, semanas después. Esa diferencia de fricción, medida en minutos, ha determinado la postura de seguridad de miles de aplicaciones y de los datos de millones de personas. La lección de ingeniería trasciende el llavero: en cualquier sistema, el camino más cómodo se convierte en el camino por defecto, y el camino por defecto se convierte en la política real, escrita o no. Por eso el trabajo maduro no consiste en saber usar el API difícil, sino en pagar una sola vez el coste de envolverlo en algo tan cómodo como la alternativa insegura — un tipo pequeño con dos métodos, probado y compartido — para que en tu equipo hacer lo correcto deje de requerir disciplina y pase a requerir solo escribir la línea obvia. La seguridad que depende de la voluntad de cada desarrollador en cada momento es, estadísticamente, seguridad ausente.
- Escribe un tipo con tres métodos — guardar, leer y borrar — que oculte por completo los diccionarios de atributos.
- Guarda un secreto con la clase de desbloqueo e intenta leerlo desde una tarea en segundo plano con el dispositivo bloqueado.
- Repite el experimento con la clase de primer desbloqueo y compara los códigos de estado obtenidos.
- Desinstala y reinstala la app: comprueba si el secreto sigue ahí y añade la limpieza en el primer arranque.
- Crea un elemento con control de acceso biométrico y verifica qué ocurre al registrar una huella nueva.