Almacenar una licencia activada en el cliente: DPAPI, caché a prueba de manipulación y en qué no confiar
La activación entrega al cliente un contrato (lease) firmado; luego llega la pregunta que nadie planifica: ¿dónde lo guardas? Déjalo en un archivo plano y un usuario edita la caducidad; cífralo mal y habrás atado la aplicación a una sola máquina por accidente. La caché sirve para la disponibilidad, no para la autenticidad: la firma sigue decidiendo si un contrato es real, así que el almacén solo tiene tres tareas: sobrevivir a un reinicio, resistir la edición casual y rechazar un contrato copiado desde otra máquina. Aquí tienes cómo sellar un contrato en caché con DPAPI donde exista, vincularlo al dispositivo con un HMAC para detectar una caché editada o trasplantada, y por qué nunca debes confiar en la caducidad en caché por encima de la firmada.
La activación es la parte que todo el mundo diseña con cuidado: el cliente se identifica, el servidor emite un contrato (lease) firmado, la aplicación lo verifica y se ejecuta. Luego el contrato tiene que vivir en algún sitio, y esa es la parte que deshace silenciosamente el diseño cuidadoso. Escríbelo en un archivo plano y un usuario desplaza la caducidad una década. Protégelo contra copia de forma torpe y habrás atado la aplicación a una máquina por accidente, de modo que una reinstalación deja fuera a un cliente que paga. La capa de almacenamiento parece fontanería y se comporta como política.
La idea clarificadora es esta: la caché sirve para la disponibilidad, no para la autenticidad. El contrato firmado ya es a prueba de manipulación: cambia una reclamación y la firma deja de verificar. Así que el almacén local tiene exactamente tres tareas: sobrevivir a un reinicio para que la aplicación arranque sin conexión, resistir la edición casual para que un usuario no pueda trastearlo trivialmente, y rechazar un contrato trasplantado desde otra máquina. No decide si el contrato es válido; eso lo hace la firma. Este artículo explica cómo Keyright persiste un contrato activado: sellándolo con DPAPI donde exista, vinculándolo al dispositivo con un HMAC, y la única regla que mantiene honesto todo el sistema: nunca confiar en la caducidad en caché por encima de la firmada.
La forma de lo que almacenas
Tras la activación tienes un contrato firmado —reclamaciones más una firma— y algunos metadatos sobre cuándo renovarlo. Almacena el contrato tal cual; todo lo demás es una pista:
public sealed record CachedLease(
string SignedLease, // the lease exactly as issued: claims + signature, opaque to the client
DateTimeOffset CachedAt, // when we wrote it — a REFRESH hint, never an authority on validity
string DeviceTag); // HMAC over SignedLease, keyed by the device fingerprint
Fíjate en lo que no está aquí: ningún expiresOn en texto plano, ningún tier, ningún booleano isValid. En el momento en que almacenas una cómoda copia sin firmar de una reclamación, has creado algo que un usuario puede editar y algo en lo que tu código podría tener la tentación de confiar. El único campo que contiene reclamaciones es SignedLease, y lees las reclamaciones de él solo después de verificar la firma.
Séllalo en reposo con DPAPI (donde exista)
En Windows, ProtectedData cifra el blob con una clave derivada de las credenciales del usuario actual. Eso compra confidencialidad en reposo y una vinculación débil a dispositivo/usuario gratis: los mismos bytes no se descifrarán como otro usuario ni en otra máquina:
static byte[] Protect(byte[] plaintext) =>
OperatingSystem.IsWindows()
? ProtectedData.Protect(plaintext, optionalEntropy: null, DataProtectionScope.CurrentUser)
: KeychainOrFingerprintEncrypt(plaintext); // Linux/macOS fallback
static byte[] Unprotect(byte[] sealed_) =>
OperatingSystem.IsWindows()
? ProtectedData.Unprotect(sealed_, optionalEntropy: null, DataProtectionScope.CurrentUser)
: KeychainOrFingerprintDecrypt(sealed_);
ProtectedData es solo para Windows: en Linux y macOS lanza PlatformNotSupportedException, así que el plan alternativo usa el llavero del sistema operativo o una clave derivada de la huella del dispositivo. Sé honesto sobre lo que es esta capa: es confidencialidad en reposo, no autenticidad. Un usuario con su propio inicio de sesión aún puede descifrar su propia caché. Eso está bien, porque el cifrado nunca fue lo que le impedía editar un contrato: eso lo hace la firma.
Vincula la copia al dispositivo
La vinculación al dispositivo de DPAPI es incidental y solo para Windows; hazla explícita y multiplataforma con un HMAC sobre el contrato almacenado, con una clave derivada de la huella del dispositivo. Al cargar, recalcúlalo a partir de la huella de esta máquina y compara:
static string DeviceTag(string signedLease, DeviceFingerprint fp)
{
using var h = new HMACSHA256(fp.DeriveKey()); // key bound to this device
var mac = h.ComputeHash(Encoding.UTF8.GetBytes(signedLease));
return Convert.ToBase64String(mac);
}
static bool BelongsHere(CachedLease c, DeviceFingerprint fp) =>
CryptographicOperations.FixedTimeEquals(
Convert.FromBase64String(c.DeviceTag),
Convert.FromBase64String(DeviceTag(c.SignedLease, fp)));
Copia el archivo de caché entero a otra máquina y la huella difiere, así que la etiqueta recalculada no coincidirá con c.DeviceTag; BelongsHere devuelve false, descartas la caché y la aplicación se reactiva, cosa que el servidor puede rechazar si la nueva máquina supera su número de puestos. Usa FixedTimeEquals, no ==, para que la comparación no filtre tiempos. Esto no es irrompible: un atacante que también replique las entradas de la huella lo vence. El objetivo es convertir “copiar un archivo” en “clonar también la identidad del dispositivo”, el aumento de coste exacto que existe para imponer el bloqueo por nodo.
La ruta de carga, en el único orden que es seguro
Pon las tres tareas en secuencia y la única regla —la autenticidad viene de la firma, nunca de la caché— surge de forma natural:
public LicenseClaims LoadOrActivate(DeviceFingerprint fp)
{
if (TryReadCache(out var raw) &&
TryUnprotect(raw, out var cached) && // gate 1: at-rest seal intact
BelongsHere(cached, fp)) // gate 2: this machine
{
if (_keySet.TryVerify(cached.SignedLease, out var claims)) // gate 3: authenticity
return claims; // gate 4: trust CLAIMS, not the file
}
return Activate(fp); // any failure: re-activate; the server enforces seats + revocation
}
La regla que lo mantiene honesto
Lee la caducidad que aplicas de claims —la salida de una verificación de firma exitosa— y de ningún otro sitio. El propio CachedAt de la caché es útil solo para decidir cuándo renovar, nunca si la licencia es válida. En el instante en que comparas “ahora” contra una marca de tiempo sin firmar almacenada junto al blob, le has entregado al usuario un campo que editar. El retroceso de fecha es su propio ataque con su propia defensa (confiar en el reloj); la contribución de la capa de almacenamiento es simplemente no introducir nunca una caducidad sin firmar que alguien pueda manipular.
La misma disciplina se aplica a los fallos. Cuando cualquier puerta falla, no parcheas la caché: la descartas y reactivas. La reactivación es el momento en que el servidor puede aplicar límites de puestos y revocación, de modo que una caché trasplantada o editada no solo falla localmente: devuelve al cliente a la única comprobación que puede decir “no”.
Qué llevarse
Una licencia activada tiene que persistir, y el almacén es donde un licenciamiento cuidadoso se filtra silenciosamente si lo permites. Mantén la caché en su verdadera tarea: disponibilidad, no autenticidad. Almacena el contrato firmado tal cual, sin cómodas copias sin firmar de sus reclamaciones. Séllalo en reposo con DPAPI en Windows (y con un llavero o una clave derivada de la huella en otros sistemas), vincula la copia al dispositivo con un HMAC para detectar un archivo trasplantado, y verifica la firma antes de leer una sola reclamación. Aplica la caducidad desde las reclamaciones verificadas, trata las marcas de tiempo del archivo solo como pistas de renovación, y ante cualquier fallo descarta y reactiva para que el servidor siga siendo la autoridad sobre puestos y revocación. Haz eso y la capa de almacenamiento deja de ser el punto débil bajo un diseño de activación por lo demás sólido.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.