🔐 Auditoría de Seguridad

Kavynox — Gestor de Contraseñas  ·  WPF + MAUI  ·  Revisión post-implementación
Versión
1.9.0
Plataformas
Windows WPF · Android MAUI
Fecha
Mayo 2026
Alcance
Código fuente completo

1. Resumen ejecutivo

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.

0
Críticas
0
Altas
0
Medias
1
Bajas
9
Implementadas
IDDescripciónSeveridadPlataformaEstado
M-01LegacyKey hardcoded en el binarioMediaWPF✅ Resuelto (mayo 2026)
M-02JWT token en texto plano en settings.json (MAUI)MediaMAUI✅ Resuelto
M-03Clave de sesión como string .NET — no zereableMediaWPF✅ Resuelto
B-01Código de recuperación cifrado con AES-CBC sin autenticaciónBajaWPF + MAUI✅ Resuelto
B-02db_key no se re-keya al cambiar la contraseña maestra (v2/v3)BajaWPF✅ Resuelto
B-03IKM de Argon2id sin separador de longitud entre los dos factoresBajaWPF + MAUI⏳ Aparcado
B-04Fallback silencioso a LegacyKey si la sesión no tiene claveBajaWPF✅ Resuelto
I-01Huellas de dispositivo (MAC, GUID, usuario SO) enviadas al servidorInfoWPF + MAUI✅ Resuelto
I-02Sin certificate pinning en las comunicaciones HTTPSInfoWPF + MAUI✅ Resuelto
I-03Log de errores de sync escribe excepciones a disco sin límiteInfoWPF✅ Resuelto
Hardening adicional — revisión externa
HA-01Threat model del kdfVersion=3 no documentado explícitamente en la UIInfoWPF + MAUI📝 Documentar
HA-02Sin detección de root / anti-hooking en AndroidInfoMAUI🔵 Futuro
HA-03Auditoría del backend API pendiente de realizarInfoAPI🔵 Fase futura

2. Arquitectura criptográfica actual

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.

kdfVersion 1 — PBKDF2-SHA256 (legacy, en eliminación)

master_key= PBKDF2-SHA256(password, SaltMaestra, 300 000 iter, 32 B)
db_key= master_key (sin separación de claves)
data_key= master_key (sin separación de claves)
estadoEliminado por auto-migración a Profesional en v1.7. Residual en el último usuario pendiente de actualizar.

kdfVersion 2 — Argon2id Profesional

IKM= UTF8(password) ∥ UTF8(password)
master_key= Argon2id(IKM, SaltMaestra, 64 MB, 3 iter, par=1, 32 B)
db_key= HKDF-SHA256(master_key, info="kavynox_db_v2", 32 B)
data_key= HKDF-SHA256(master_key, info="kavynox_data_v2", 32 B)
re-key✓ Vault SQLCipher re-keyado con nueva db_key al cambiar contraseña (B-02)

kdfVersion 3 — Argon2id Blindado

IKM= UTF8(password) ∥ UTF8(second_factor)
master_key= Argon2id(IKM, SaltMaestra, 64 MB, 3 iter, par=1, 32 B)
db_key= HKDF-SHA256(master_key, info="kavynox_db_v2", 32 B)
data_key= HKDF-SHA256(master_key, info="kavynox_data_v2", 32 B)
second_factor= Persiste en DPAPI (Windows) / Android Keystore (MAUI). El servidor solo recibe el KeyVerifier (AES-256-GCM sobre texto fijo).
re-key✓ Vault SQLCipher re-keyado con nueva db_key al cambiar contraseña (B-02)
modelo amenaza⚠ Protege contra robo del vault offline. No protege si el dispositivo está desbloqueado y comprometido. Es un "device-bound secret", no un segundo factor clásico independiente del dispositivo. Documentar explícitamente en la UI — ver §5 (Hardening adicional, HA-01).

Cifrado de entradas — AES-256-GCM v3

algoritmo= AES-256-GCM — autenticado, nonce 12 B aleatorio, tag 16 B
formato v3= "3:" + base64(nonce ∥ tag ∥ ciphertext)
nonce= 12 bytes aleatorios por campo y por escritura (RandomNumberGenerator)
vault DB= SQLCipher AES-256 (cifrado a nivel de archivo)

Cifrado del código de recuperación — AES-256-GCM (B-01)

algoritmo= AES-256-GCM — autenticado, nonce 12 B, tag 16 B
formato= "R:" + base64(nonce[12] ∥ tag[16] ∥ ciphertext)
detección= Prefijo "R:" distingue el formato GCM del formato CBC legacy para compatibilidad con códigos antiguos emitidos
plataformas= WPF + MAUI (código compartido)

3. Medidas de seguridad implementadas

Las siguientes mejoras han sido verificadas en el código fuente. Todas están activas en la versión actual.

M-02 JWT almacenado en Android Keystore mediante SecureStorage (MAUI) ✅ Implementado
Archivo
Services/SettingsService.cs (MAUI)
Plataforma
Android / MAUI

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"));
M-03 Clave de sesión del vault almacenada como byte[] zereable en memoria (WPF) ✅ Implementado
Archivo
Services/VaultConstants.cs (WPF)
Plataforma
WPF

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;
    }
    // ...
}
B-01 Código de recuperación migrado a AES-256-GCM con autenticación (WPF + MAUI) ✅ Implementado
Archivo
Services/EncriptacionService.cs (WPF + MAUI)
Plataforma
WPF + MAUI

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
B-02 Vault SQLCipher re-keyado al cambiar la contraseña maestra (WPF) ✅ Implementado
Archivo
Services/DbPathService.cs · ViewModels/CambioContrasenaViewModel.cs (WPF)
Plataforma
WPF

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);
B-04 Fallback silencioso a LegacyKey reemplazado por excepción descriptiva (WPF) ✅ Implementado
Archivo
Services/VaultConstants.cs (WPF)
Plataforma
WPF

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
    }
}
I-01 Fingerprinting de dispositivo eliminado del payload de login (WPF + MAUI) ✅ Implementado
Archivo
Services/SyncService.cs (WPF + MAUI)
Plataforma
WPF + MAUI

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();
}
I-02 Certificate pinning implementado en dos capas (MAUI Android) ✅ Implementado
Archivos
network_security_config.xml · CertificatePinningHandler.cs · SyncService.cs (MAUI)
Plataforma
MAUI Android (capa SO) · WPF / otras plataformas (código)

Defensa en dos capas independientes:

// 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)
I-03 Log de errores de sync con límite de tamaño y sanitización (WPF) ✅ Implementado
Archivo
Services/SyncService.cs (WPF)
Plataforma
WPF

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}");

4. Pendiente para máxima seguridad

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.

M-01 LegacyKey hardcoded en el binario (WPF) ✅ Resuelto — mayo 2026
Archivos modificados
VaultKeyService.cs · DbPathService.cs · LoginViewModel.cs · CambioContrasenaViewModel.cs · KdfMigrationService.cs
Plataforma
WPF
Estado
Eliminado en producción — verificado con registro y login

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:

B-03 IKM de Argon2id: concatenación de factores sin separador de longitud Baja — aparcado
Archivo
Services/KdfService.cs (WPF + MAUI)
Plataforma
WPF + MAUI
Impacto práctico
Nulo en uso real
Estado
Aparcado — coste desproporcionado

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);

5. Hardening adicional recomendado

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.

HA-01 Threat model del kdfVersion=3 ("segundo factor") no documentado explícitamente Info — documentación
Plataforma
WPF + MAUI
Esfuerzo
Bajo — texto en UI
Estado
Pendiente de redactar

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:

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."

HA-02 Sin detección de root / anti-hooking en Android Info — baja prioridad actual
Plataforma
MAUI Android
Esfuerzo
Medio-alto
Estado
Aparcado — revisar si escala

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.

HA-03 Auditoría del backend API pendiente de realizar Info — fase futura
Plataforma
API (VPS)
Alcance
Fuera del alcance de esta auditoría
Estado
Fase futura separada

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.

6. Fortalezas de seguridad

7. Calendario de mantenimiento

Acciones programadas

2026-08-01 Renovar certificado leaf de 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
2027-03-12 Expiración CA intermedia Let's Encrypt R12. Para entonces, añadir el pin de la nueva CA intermedia al pin-set y actualizar la fecha de expiración en el XML.
Mayo 2026 ✅ LegacyKey eliminada (M-01): constante borrada, CurrentKey lanza excepción incondicional, toda la infraestructura V1 eliminada. Verificado con registro y login en producción.
Inmediato Configurar monitorización automática de expiración del certificado. El pinning es el mayor riesgo operativo: si el certificado renueva y no se actualiza el pin, todos los usuarios Android pierden la sincronización sin aviso. Opciones: (a) script cron en el VPS que compruebe la fecha de expiración del certificado de 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.