Kavynox es seguro para almacenar contraseñas personales y profesionales. Esta auditoría, realizada sobre el código fuente completo de los tres proyectos que componen la solución (WPF, MAUI y API), no ha encontrado ninguna vulnerabilidad crítica ni alta. El sistema no puede ser comprometido de forma remota: el servidor opera en modo zero-knowledge, lo que significa que en ningún momento tiene acceso a la contraseña maestra del usuario, a las claves de cifrado ni al contenido de las entradas. Todo lo que el servidor almacena son bloques de datos cifrados que resultan indistinguibles de ruido aleatorio sin la clave que solo el usuario conoce.
La arquitectura criptográfica está construida sobre los estándares más exigentes de la industria. El cifrado de entradas utiliza AES-256-GCM, el mismo algoritmo que emplean soluciones como Signal o WhatsApp: proporciona confidencialidad y autenticidad simultáneamente, lo que hace imposible modificar o falsificar datos sin que la aplicación lo detecte. La derivación de la clave maestra utiliza Argon2id —ganador del Password Hashing Competition de 2015— con parámetros (64 MB de memoria, 3 iteraciones) que superan ampliamente las recomendaciones mínimas de OWASP, haciendo que los ataques de fuerza bruta sean computacionalmente inviables incluso con hardware especializado. Las claves del vault de base de datos y del cifrado de entradas se derivan de forma independiente mediante HKDF-SHA256, de modo que comprometer una no implica comprometer la otra.
Más allá del núcleo criptográfico, la auditoría ha verificado la correcta implementación de múltiples capas de defensa adicionales: certificate pinning en dos capas para prevenir ataques de intermediario incluso en redes comprometidas; almacenamiento del token de sesión protegido por DPAPI en Windows y Android Keystore en Android, los mecanismos de seguridad del propio sistema operativo; borrado de claves en memoria (ZeroMemory) al cerrar sesión, para no dejar trazas en la RAM; y re-cifrado completo del vault al cambiar la contraseña, garantizando que los datos antiguos no puedan recuperarse con una contraseña anterior.
De los diez hallazgos identificados durante la auditoría inicial, nueve han sido corregidos y verificados en el código de producción. El único hallazgo pendiente tiene severidad baja y carácter puramente teórico: una ambigüedad en la construcción del material de entrada de Argon2id que, en la práctica, no tiene ningún escenario de explotación conocido ni demostrable con los parámetros actuales. No existe ningún hallazgo de severidad media, alta o crítica sin resolver.
| ID | Descripción | Severidad | Plataforma | Estado |
|---|---|---|---|---|
| M-01 | LegacyKey hardcoded en el binario | Media | WPF | ✅ Resuelto (mayo 2026) |
| M-02 | JWT token en texto plano en settings.json (MAUI) | Media | MAUI | ✅ Resuelto |
| M-03 | Clave de sesión como string .NET — no zereable | Media | WPF | ✅ Resuelto |
| B-01 | Código de recuperación cifrado con AES-CBC sin autenticación | Baja | WPF + MAUI | ✅ Resuelto |
| B-02 | db_key no se re-keya al cambiar la contraseña maestra (v2/v3) | Baja | WPF | ✅ Resuelto |
| B-03 | IKM de Argon2id sin separador de longitud entre los dos factores | Baja | WPF + MAUI | ⏳ Aparcado |
| B-04 | Fallback silencioso a LegacyKey si la sesión no tiene clave | Baja | WPF | ✅ Resuelto |
| I-01 | Huellas de dispositivo (MAC, GUID, usuario SO) enviadas al servidor | Info | WPF + MAUI | ✅ Resuelto |
| I-02 | Sin certificate pinning en las comunicaciones HTTPS | Info | WPF + MAUI | ✅ Resuelto |
| I-03 | Log de errores de sync escribe excepciones a disco sin límite | Info | WPF | ✅ Resuelto |
| Hardening adicional — revisión externa | ||||
| HA-01 | Threat model del kdfVersion=3 no documentado explícitamente en la UI | Info | WPF + MAUI | 📝 Documentar |
| HA-02 | Sin detección de root / anti-hooking en Android | Info | MAUI | 🔵 Futuro |
| HA-03 | Auditoría del backend API pendiente de realizar | Info | API | 🔵 Fase futura |
La aplicación implementa tres versiones del esquema KDF que coexisten con compatibilidad hacia atrás. Las versiones 2 y 3 garantizan separación de claves entre el vault SQLCipher y los datos de las entradas. La auto-migración introducida en v1.7 elimina activamente los vaults V1.
Las siguientes mejoras han sido verificadas en el código fuente. Todas están activas en la versión actual.
El JWT nunca se escribe en settings.json. El campo está marcado con [JsonIgnore] para impedir serialización accidental. El token se persiste exclusivamente en el Android Keystore a través de SecureStorage, protegido por el hardware del dispositivo. La carga al arranque es asíncrona y falla de forma segura (el usuario vuelve a hacer login) si el Keystore no puede desproteger el token.
// settings.json — JwtToken nunca aparece
[JsonIgnore]
public string? JwtToken { get; set; }
// Persistencia en Android Keystore
await SecureStorage.Default.SetAsync("kavynox_jwt", token);
await SecureStorage.Default.SetAsync("kavynox_jwt_expira", expira.ToString("O"));
byte[] zereable en memoria (WPF)
✅ Implementado
La db_key de sesión v2/v3 se almacena en _sessionKeyBytes (byte[]?). Al hacer logout, VaultKeyService.Clear() aplica CryptographicOperations.ZeroMemory sobre el array antes de nullearlo, sobreescribiendo los bytes en el heap. El hex string para la cadena de conexión SQLCipher se genera al vuelo en el getter de CurrentKey, minimizando su vida útil en memoria. La ruta v1 (PBKDF2) mantiene el string por compatibilidad.
⚠️ Limitación conocida de plataforma: Convert.ToHexString(_sessionKeyBytes) genera un string inmutable en el heap gestionado de .NET que no puede zerearse explícitamente. No es evitable: el wrapper .NET de SQLCipher requiere un string para la contraseña de conexión. La mitigación aplicada consiste en no cachear el valor — el getter lo genera en cada llamada y se descarta inmediatamente después. Esto minimiza la ventana de exposición, pero la liberación efectiva de memoria depende del GC. Es una limitación estructural de .NET + SQLCipher compartida por todos los password managers .NET con la misma arquitectura, no un error de diseño de la aplicación.
private static byte[]? _sessionKeyBytes = null;
public static string CurrentKey
{
get
{
// v2/v3 — hex al vuelo, los bytes son zereables
if (_sessionKeyBytes != null)
return Convert.ToHexString(_sessionKeyBytes).ToLowerInvariant();
// ...
}
}
public static void Clear()
{
if (_sessionKeyBytes != null)
{
CryptographicOperations.ZeroMemory(_sessionKeyBytes);
_sessionKeyBytes = null;
}
// ...
}
CifrarClaveParaRecuperacion usa AES-256-GCM con nonce aleatorio de 12 bytes y tag de 16 bytes. El formato de salida incluye el prefijo "R:" que permite distinguirlo del formato CBC legacy. RestaurarClaveDesdeRecuperacion detecta automáticamente el formato: si empieza por "R:" descifra con GCM (autenticado, detecta manipulación del ciphertext); en caso contrario usa la ruta CBC para los códigos de emergencia ya emitidos, garantizando compatibilidad total hacia atrás.
// Nuevo formato GCM — nonce[12] ∥ tag[16] ∥ ciphertext
RandomNumberGenerator.Fill(nonce);
using var gcm = new AesGcm(claveRecuperacion, tagSizeInBytes: 16);
gcm.Encrypt(nonce, _claveMaestra, cifrado, tag);
return "R:" + Convert.ToBase64String(resultado);
// Restauración — detecta formato automáticamente
if (claveCifradaBase64.StartsWith("R:", StringComparison.Ordinal))
// → ruta GCM autenticada (detecta cualquier manipulación)
else
// → ruta CBC legacy para códigos anteriores a esta versión
DbPathService.RekeyVault(oldKey, newKey) usa el mecanismo sqlcipher_export (la misma técnica de las migraciones KDF): crea un vault temporal cifrado con la nueva clave, sustituye atómicamente el fichero original y limpia los pools de conexión. CambioContrasenaViewModel lo llama al final del cambio de contraseña para ambas rutas v1 y v2/v3, eliminando la ventana temporal en la que el fichero .db permanecía cifrado con la clave anterior.
// Flujo v2/v3 — al finalizar el cambio de contraseña
var newDbKeyBytes = KdfService.DerivarDbKey(masterKeyNew);
DbPathService.RekeyVault(oldKeyHex, newKeyHex); // sqlcipher_export atómico
VaultKeyService.SetDerivedKey(newDbKeyBytes);
CryptographicOperations.ZeroMemory(newDbKeyBytes);
VaultKeyService.CurrentKey detecta el estado inconsistente mediante el flag _claveModernaEstablecida: si SetDerivedKey fue llamado en esta sesión pero los bytes ya no están disponibles (Clear invocado antes de tiempo), lanza una InvalidOperationException con mensaje diagnóstico claro en lugar de devolver la LegacyKey. El fallback a LegacyKey solo ocurre cuando nunca se estableció ninguna clave derivada, lo que corresponde a vaults V1 pre-migración.
public static string CurrentKey
{
get
{
if (_sessionKeyBytes != null) return Convert.ToHexString(...);
if (!string.IsNullOrEmpty(_sessionKey)) return _sessionKey;
// Error descriptivo — solo si hubo SetDerivedKey previo
if (_claveModernaEstablecida)
throw new InvalidOperationException(
"VaultKeyService: la clave derivada v2/v3 fue establecida en esta " +
"sesión pero ya no está disponible...");
return LegacyKey; // solo para vaults V1 pre-v1.3
}
}
Los campos mac y usuarioSO se envían como null en ambas plataformas. El deviceId ya no usa el MachineGuid del registro de Windows (identificador hardware permanente cross-app) ni el ANDROID_ID directamente: WPF genera un UUID pseudónimo en el primer arranque y lo persiste en settings.json; MAUI usa el ANDROID_ID con scope por app+usuario (no hardware real). El campo dispositivo se conserva porque es el identificador usado por la función de bloqueo de dispositivos en el servidor.
// WPF — SyncService.InfoDispositivo() (ahora no-estático)
deviceId = _settingsService.Settings.DeviceId, // UUID app-específico
usuarioSO = (string?)null, // eliminado
mac = (string?)null // eliminado
// WPF — SettingsService: genera UUID en primer arranque
if (string.IsNullOrWhiteSpace(_settings.DeviceId))
{
_settings.DeviceId = Guid.NewGuid().ToString("N");
Guardar();
}
Defensa en dos capas independientes:
network_security_config.xml referenciado en AndroidManifest.xml. El SO Android rechaza cualquier conexión TLS a passwords.kavynox.com cuya cadena no incluya alguno de los dos pins SPKI SHA-256 (leaf + CA intermedia Let's Encrypt R12). Tráfico HTTP en claro bloqueado globalmente. Expiración configurada al 2027-04-15.CertificatePinningHandler valida los mismos pins SPKI SHA-256 mediante ServerCertificateCustomValidationCallback. En Android no se usa (el SO ya lo garantiza y SocketsHttpHandler ignoraría el XML).// SyncService.cs — handler según plataforma
HttpMessageHandler certHandler =
#if ANDROID
new Xamarin.Android.Net.AndroidMessageHandler(); // SO aplica network_security_config.xml
#else
new CertificatePinningHandler(); // validación SPKI en código
#endif
// network_security_config.xml — pins SPKI SHA-256
// Leaf: PAynogswE2afoIRddjwbZ7kYruOCM8kWysq902M1q4A= (renovar antes 2026-08-01)
// R12: kZwN96eHtZftBWrOZUsd6cA4es80n3NzSk/XtYz2EqQ= (cubre renovaciones hasta 2027-03-12)
El fichero sync_error.log se trunca a cero cuando supera 100 KB. Solo se registra el tipo de excepción y el código HTTP (nunca el cuerpo de la respuesta, rutas de archivo ni mensajes internos del vault). Ante un escenario de conectividad intermitente, el fichero no puede crecer indefinidamente ni filtrar datos internos.
if (_fi.Exists && _fi.Length > 100 * 1024)
File.WriteAllText(logPath, string.Empty); // rotación por truncado
var _tipo = ex.GetType().Name; // solo el tipo
var _codigo = ex is HttpRequestException hre
? ((int?)hre.StatusCode)?.ToString() ?? "net"
: "-";
File.AppendAllText(logPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {_tipo} HTTP={_codigo}{Environment.NewLine}");
El hallazgo siguiente representa el gap restante entre la postura de seguridad actual y la teóricamente óptima. Se describe con su contexto completo para facilitar la toma de decisiones.
Descripción original: La clave usada para cifrar los vaults creados antes de la versión 1.3 estaba compilada como constante directamente en el ensamblado (LegacyKey = "K@vyn0x#S3cur3!..."). Con acceso al ejecutable y al fichero .db de un usuario V1, era posible abrir el vault directamente con SQLCipher.
Resolución: Confirmado que no quedan usuarios con kdfVersion=1, se procedió a la eliminación completa:
LegacyKey eliminada de VaultKeyService. CurrentKey lanza excepción incondicional si no hay clave de sesión activa..meta, MigrarAEncriptadoSiNecesario, MigrarAClaveDerivada, DeriveVaultKey, SetKey, MigrarVaultSiNecesarioAsync y toda la lógica condicional KdfVersion == 1.EnsureCreatedAsync; re-cifrado con clave Argon2id definitiva por KdfMigrationService en el primer login.Descripción: El IKM de Argon2id se construye concatenando directamente los bytes UTF-8 de los dos factores sin separador de longitud:
var ikm = new byte[f1.Length + f2.Length];
f1.CopyTo(ikm, 0);
f2.CopyTo(ikm, f1.Length);
Esta construcción es susceptible a colisiones de longitud: ("ab", "cd") y ("abc", "d") producen el mismo IKM "abcd". En la práctica el impacto es nulo porque en v2 ambos factores son idénticos y en v3 el usuario elige sus factores de forma independiente sin conocer este detalle. No abre ningún vector de ataque práctico.
Por qué está aparcado: Cambiar la construcción del IKM invalida todas las claves derivadas existentes y requeriría introducir una nueva kdfVersion=4 con migración forzada y re-cifrado de todos los vaults de todos los usuarios. El coste operativo y el riesgo de introducir bugs durante la migración son desproporcionados respecto a una vulnerabilidad sin explotabilidad práctica demostrada.
Solución técnica (para implementar si se decide avanzar):
// Prefijo de 4 bytes (little-endian) con la longitud de cada factor:
var len1 = BitConverter.GetBytes(f1.Length);
var ikm = new byte[4 + f1.Length + 4 + f2.Length];
len1.CopyTo(ikm, 0);
f1.CopyTo(ikm, 4);
BitConverter.GetBytes(f2.Length).CopyTo(ikm, 4 + f1.Length);
f2.CopyTo(ikm, 8 + f1.Length);
Observaciones de la revisión externa que no forman parte de los hallazgos originales de la auditoría. Ninguna representa una vulnerabilidad activa; son mejoras de documentación, experiencia de usuario y postura defensiva de cara al crecimiento del producto.
El segundo factor de kdfVersion=3 se persiste en DPAPI (Windows) o Android Keystore (MAUI), vinculado al perfil del usuario y al dispositivo. Esto tiene un modelo de amenaza preciso que conviene comunicar correctamente:
.db sin acceso al dispositivo. Un atacante que obtenga el vault pero no el dispositivo desbloqueado no puede derivar las claves sin conocer ambos factores.El riesgo no está en la implementación — está en la comunicación. Si el usuario entiende que ha activado "2FA" en el sentido clásico (TOTP, hardware key), tendrá falsas expectativas de seguridad. El término correcto para este diseño es "secreto vinculado al dispositivo" (device-bound secret).
Acción recomendada: añadir en la pantalla de activación del segundo factor una línea explicativa del tipo: "Este segundo factor protege tu vault si alguien roba el archivo de datos, pero no reemplaza un 2FA de cuenta independiente del dispositivo."
La aplicación no implementa actualmente ninguna forma de detección de dispositivos rooteados, frameworks de hooking (Frida, Xposed), ni utiliza la Play Integrity API para verificar la integridad del entorno de ejecución.
En un dispositivo Android rooteado o con un framework de hooking activo, un atacante con acceso físico podría en principio:
Contexto: Android es el punto de ataque más expuesto para aplicaciones de este tipo. Sin embargo, este vector requiere acceso físico al dispositivo rooteado, lo que sale del modelo de amenaza habitual de la mayoría de usuarios. La Play Integrity API es la defensa más directa pero depende de Google Play Services y añade complejidad operativa.
Recomendación: aparcar en la escala actual. Revisar si el volumen de usuarios crece significativamente o si aparecen indicios de ataques dirigidos. Si se decide implementar, la Play Integrity API más una comprobación básica de indicadores de root (presencia de su, test de escritura en /system) cubren el 80% de los casos.
La presente auditoría cubre exclusivamente el código cliente (WPF + MAUI). La criptografía del lado cliente está verificada y es sólida. Sin embargo, en aplicaciones de gestión de contraseñas muchas vulnerabilidades críticas aparecen en el backend, independientemente de lo bien implementada que esté la criptografía cliente.
Áreas del backend pendientes de auditar:
Nota: el diseño zero-knowledge del cliente (los blobs llegan al servidor ya cifrados con claves que el servidor nunca conoce) limita significativamente el impacto de una brecha del backend. Un atacante que comprometa el servidor solo obtiene blobs opacos. Esto no elimina el riesgo pero sí reduce considerablemente el valor del objetivo.
CryptographicOperations.ZeroMemory al logout garantiza que la db_key no queda en el heap del proceso (M-03).SecureStorage, protegido por hardware. [JsonIgnore] impide serialización accidental a disco (M-02).network_security_config.xml a nivel de SO + CertificatePinningHandler a nivel de código para otras plataformas. Pins SPKI SHA-256 del leaf y de la CA intermedia (I-02).usuarioSO eliminado del payload (I-01).RandomNumberGenerator.Fill — resistentes a tablas rainbow.network_security_config.xml con cleartextTrafficPermitted="false".passwords.kavynox.com. Actualizar el pin SPKI SHA-256 en network_security_config.xml y CertificatePinningHandler.cs ANTES de que expire. Comando para obtener el nuevo pin: openssl s_client -connect passwords.kavynox.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | openssl enc -base64
pin-set y actualizar la fecha de expiración en el XML.
CurrentKey lanza excepción incondicional, toda la infraestructura V1 eliminada. Verificado con registro y login en producción.
passwords.kavynox.com y envíe alerta por email a los 30, 15 y 7 días antes; (b) servicio externo de monitorización SSL (UptimeRobot, Freshping, etc.). El riesgo principal del certificate pinning no es seguridad — es disponibilidad.