ProtectedJson Integrando configuración y protección de datos en ASP.NET Core
Un proveedor de configuración JSON mejorado que permite el cifrado parcial o completo de valores en appsettings.json
Introducción
ProtectedJson
es un proveedor de configuración JSON mejorado que permite el cifrado parcial o completo de los valores de configuración almacenados en archivos appsettings.json y completamente integrado en la arquitectura ASP.NET Core. Básicamente, implementa un ConfigurationSource
personalizado y un ConfigurationProvider
personalizado que descifra todos los datos cifrados incluidos en una etiqueta de tokenización personalizada dentro de los valores JSON utilizando la API de protección de datos de ASP.NET Core.
Antecedentes
ASP.NET Configuration es la forma estándar de .NET Core de almacenar los datos de configuración de la aplicación a través de pares clave-valor jerárquicos dentro de una variedad de fuentes de configuración (generalmente archivos JSON, pero también variables de entorno, bóvedas de claves, tablas de bases de datos o cualquier proveedor personalizado que desee implementar). Mientras que .NET Framework utilizaba una única fuente (generalmente, un archivo XML que intrínsecamente era más detallado), .NET Core puede utilizar múltiples fuentes de configuración ordenadas, que se “fusionan” permitiendo el concepto de anulación del valor de una clave en una fuente de configuración por el mismo presente en una fuente de configuración posterior. Esto es útil porque en el desarrollo de software, generalmente hay múltiples entornos (Desarrollo, Integración, PRE-Producción y Producción) y cada entorno tiene su propia configuración personalizada (por ejemplo, puntos finales de API, cadenas de conexión a bases de datos, variables de configuración diferentes, etc.). En .NET Core, este manejo es sencillo, de hecho, generalmente tienes dos archivos JSON:
- appsettings.json: que contiene los parámetros de configuración comunes a todos los entornos.
- appsettings.<nombre del entorno>.json: que contiene los parámetros de configuración específicos del entorno particular.
Las aplicaciones de ASP.NET Core generalmente configuran y lanzan un host. El host es responsable del inicio de la aplicación, la configuración de la inyección de dependencias y los servicios en segundo plano, la configuración del registro, la gestión del ciclo de vida y, obviamente, la configuración de la configuración de la aplicación. Esto se hace principalmente de dos maneras:
- Implícitamente, mediante el uso de uno de los métodos proporcionados por el framework, como
WebApplication.CreateBuilder
oHost.CreateDefaultBuilder
(generalmente llamados dentro del archivo fuente Program.cs) que básicamente hacen lo siguiente:- Leer y analizar los argumentos de la línea de comandos
- Recuperar el nombre del entorno respectivamente de la variable de entorno
ASPNETCORE_ENVIRONMENT
yDOTNET_ENVIRONMENT
(establecido ya sea en las variables del sistema operativo o pasado directamente en la línea de comandos con el argumento--environment
). - Leer y analizar dos archivos de configuración JSON llamados appsettings.json y appsettings.<nombre del entorno>.json.
- Leer y analizar las variables de entorno.
- Llamar al delegado
Action<Microsoft.Extensions.Hosting.HostBuilderContext,Microsoft.Extensions.Configuration.IConfigurationBuilder>
deConfigure<wbr />App<wbr />Configuration
donde puedes configurar la configuración de la aplicación a través del parámetroIConfigurationBuilder
.
- Explícitamente, instanciando la clase
ConfigurationBuilder
y utilizando uno de los métodos de extensión proporcionados:AddCommandLine
: para solicitar el análisis de los parámetros de la línea de comandos (ya sea por--
o – o/
)AddJsonFile
: para solicitar el análisis de un archivo JSON especificando si es obligatorio u opcional y si debe recargarse automáticamente cada vez que cambia en el sistema de archivos.AddEnvironmentVariables
: para solicitar el análisis de las variables de entorno- etc.
En esencia, cada método de extensión Add<xxxx>
agrega un ConfigurationSource
para especificar la fuente de pares clave-valor (Línea de comandos, Archivo JSON, Variables de entorno, etc.) y un ConfigurationProvider
asociado utilizado para cargar y analizar los datos desde la fuente en la lista de Providers
de la interfaz IConfigurationRoot
que se devuelve como resultado del método Build
en la clase ConfigurationBuilder
como puedes ver en la siguiente imagen.
(Dentro de configuration.Providers
, tenemos cuatro fuentes: CommandLineConfigurationProvider
, dos ProtectedJsonConfigurationProvider
para appsettings.json y appsettings.<nombre del entorno>.json y finalmente EnvironmentVariableConfigurationProvider
).
Como mencioné anteriormente, el orden en el que se llaman los métodos de extensión Add<xxxx> es importante porque cuando la clase IConfigurationRoot
recupera un valor clave, utiliza el método GetConfiguration que recorre la lista de Providers
en orden inverso tratando de devolver el primero que contiene la clave consultada, simulando así una “combinación” de todas las fuentes de configuración (orden LIFO, Last In First Out).
ProtectedJson
es fundamentalmente una biblioteca de clases que define una fuente de configuración llamada ProtectedJsonConfigurationSource
que especifica el archivo de configuración y la etiqueta de tokenización, y el proveedor de configuración asociado ProtectedJsonConfigurationProvider
utilizado para analizar el archivo JSON y descifrar los valores JSON encerrados en la etiqueta de tokenización; además, también proporciona métodos de extensión estándar para vincularlos a la interfaz IConfigurationBuilder
(por ejemplo, AddProtectedJsonFile
).
Uso del código
Encuentras todo el código fuente en mi repositorio de Github, el código está basado en .NET 6.0 y Visual Studio 2022. Dentro del archivo de solución, hay dos proyectos:
FDM.Extensions.Configuration.ProtectedJson
: Esta es una biblioteca de clases que implementaProtectedJsonConfigurationSource
,ProtectedJsonConfigurationProvider
(y sus correspondientesProtectedJsonStreamConfigurationProvider
yProtectedJsonStreamConfigurationSource
basados en transmisiones) y los métodos de extensión para la interfazIConfigurationBuilder
(AddProtectedJsonFile
y sus sobrecargas).FDM.Extensions.Configuration.ProtectedJson.ConsoleTest
: Este es un proyecto de consola que muestra cómo usarJsonProtector
leyendo y analizando dos archivos de configuración personalizados y convirtiéndolos en una clase de tipo fuerte llamadaAppSettings
. El descifrado ocurre sin problemas y automáticamente con casi ninguna línea de código, veamos cómo.
Para usar ProtectedJson
, debes agregar tantos archivos JSON como desees utilizando el método de extensión AddProtectedJsonFile
de IConfigurationBuilder
que toma los siguientes parámetros:
path
: especifica la ruta y el nombre de archivo del archivo JSON (parámetro estándar)optional
: es un booleano que especifica si el archivo JSON es obligatorio o opcional (parámetro estándar)reloadOnChange
: este es un booleano que indica que el archivo JSON (y la configuración) deben recargarse automáticamente cada vez que cambia el archivo especificado en el disco (parámetro estándar).protectedRegexString
: es una expresión regularstring
que especifica la etiqueta de tokenización que encierra los datos cifrados; debe definir un grupo con nombre llamadoprotectedData
. De forma predeterminada, este parámetro asume el valor:public const string DefaultProtectedRegexString = "Protected:{(?<protectedData>.+?)}";
La expresión regular anterior busca esencialmente de manera codiciosa (para poder recuperar todas las ocurrencias dentro de un valor JSON) cualquier
string
que coincida con el patrón'Protected:{<datos cifrados>}'
y extrae la subcadena<datos cifrados>
almacenándola en un grupo con nombreprotectedData
. Si no te gusta esta tokenización, puedes reemplazarla por cualquier otra que prefieras creando una expresión regular con la restricción de que extrae la subcadena<datos cifrados>
en un grupo llamadoprotectedData
.serviceProvider
: Esta es una interfazIServiceProvider
necesaria para instanciarIDataProtectionProvider
de Data Protection API para descifrar los datos. Este parámetro es mutuamente excluyente con el siguiente.dataProtectionConfigureAction
: Esta es unaAction<IDataProtectionBuilder>
utilizada para configurar la Data Protection API en NET Core estándar. Una vez más, este parámetro es mutuamente excluyente con el anterior.
Los dos últimos parámetros son algo inconvenientes, porque representan una reconfiguración de otra inyección de dependencias para instanciar el IDataProtectionProvider
necesario para descifrar los datos.
De hecho, en una aplicación NET Core estándar, generalmente la inyección de dependencias se configura después de haber leído e interpretado el archivo de configuración (por lo que todas las fuentes y proveedores de configuración no utilizan DI), pero en este caso, me vi obligado ya que la única forma de acceder a la API de Protección de Datos es a través de DI. Además, al configurar la inyección de dependencias, la configuración interpretada generalmente se vincula a una clase fuertemente tipada mediante el uso de services.Configure<<strongly typed settings class>>(configuration)
, por lo que es una carrera sin fin (para descifrar la configuración necesitas DI, para configurar DI necesitas la configuración interpretada para vincularla a una clase fuertemente tipada). La única solución que se me ocurrió hasta ahora es reconfigurar una segunda IServiceProvider
de DI solo para la API de Protección de Datos y usarla dentro de ProtectedJsonConfigurationProvider
. Para configurar la segunda IServiceProvider
de DI tienes dos opciones: puedes crearla tú mismo (instanciando un ServiceCollection
, llamando a AddDataProtection
en ella y pasándola a AddProtectedJsonFile
) o puedes permitir que ProtectedJsonConfigurationProvider
la cree pasando un parámetro dataProtectionConfigureAction
a AddProtectedJsonFile
. En la aplicación de consola, para evitar código duplicado, la configuración de la API de Protección de Datos se realiza dentro de un método privado llamado ConfigureDataProtection
, cuya implementación es:
private static void ConfigureDataProtection(IDataProtectionBuilder builder){ builder.UseCryptographicAlgorithms(new AuthenticatedEncryptorConfiguration { EncryptionAlgorithm = EncryptionAlgorithm.AES_256_CBC, ValidationAlgorithm = ValidationAlgorithm.HMACSHA256, }).SetDefaultKeyLifetime(TimeSpan.FromDays(365*15)).PersistKeysToFileSystem (new DirectoryInfo("..\\..\\Keys"));}
Aquí, elegí utilizar el cifrado simétrico AES 256 con HMAC SHA256 como función de firma digital. Además, pido que se almacenen todos los metadatos de cifrado (claves, iv, clave del algoritmo hash) en un archivo XML dentro de la carpeta Keys de la aplicación de consola (ten en cuenta que todas estas API son proporcionadas por defecto por la API de Protección de Datos). Así que cuando ejecutas la aplicación por primera vez, la API de Protección de Datos crea automáticamente la clave de cifrado y la almacena en la carpeta Keys, en las ejecuciones siguientes, carga los datos de la clave desde este archivo XML. Sin embargo, esta configuración no es el mejor enfoque desde el punto de vista de la seguridad porque los metadatos se almacenan en texto plano, si estás en Windows, puedes eliminar el método de extensión PersistKeysToFileSystem
y en este caso, los metadatos se cifrarían a su vez con otra clave almacenada en un lugar seguro dentro de tu computadora. Por mi parte, no tengo idea de cómo la API de Protección de Datos maneja esto en Linux.
En los archivos appsetting.json y appsettings.development.json, defino pares clave-valor estándar de manera jerárquica para ejemplificar la función de fusión de la Configuración de ASP.NET Core y también el uso de valores cifrados.
Si miras la sección ConnectionStrings en appsetting.json, hay tres claves:
PlainTextConnectionString
: Como su nombre indica, contiene una cadena de conexión en texto planoPartiallyEncryptedConnectionString
: Como su nombre indica, contiene una mezcla de texto plano y múltiples etiquetas de tokenizaciónProtect:{<data to encrypt>}
. En cada ejecución, estos tokens se cifran automáticamente y se reemplazan por el tokenProtected:{<encrypted data>}
después de llamar al método de extensiónIDataProtect.ProtectFiles
.FullyEncryptedConnectionString
: Como su nombre indica, contiene un solo tokenProtect:{<data to encrypt>}
que abarca toda la cadena de conexión y que se cifra por completo después de la primera ejecución.
Si miras la sección Nullable en appsettings.development.json, puedes encontrar algunas claves interesantes:
Int, DateTime, Double, Bool
: Estas claves contienen respectivamente un entero, una fecha y hora, un número decimal y un booleano, pero todos se almacenan como unastring
utilizando una única etiquetaProtect:{<data to encrypt>}
. ¡Espera un momento, ¿cómo es esto posible?Bueno, principalmente, todos los
ConfigurationProviders
convierten inicialmente cualquierConfigurationSource
en unDictionary<String,String>
en su métodoLoad
(consulta la propiedad Data de la clase base abstracta ConfigurationProvider del framework, el métodoLoad
también aplana todas las rutas jerárquicas de la clave en una cadena separada por dos puntos, por lo que por ejemplo Nullable->Int se convierte enNullable:Int
). Solo más tarde, este diccionario se convierte y se vincula a una clase fuertemente tipada.El proceso de descifrado de
ProtectedJsonConfigurationProvider
ocurre en el medio, por lo que es transparente para el usuario y además está disponible en cualquier tipo de variable simple (DateTime
,bool
, etc.). Por ahora, no se admite el cifrado completo de una matriz entera, pero aún así puedes cifrar un solo elemento convirtiendo la matriz en una matriz destring
s (echa un vistazo a la claveDoubleArray
).
El código principal es:
public static void Main(string[] args){ // define los servicios DI: API de Protección de Datos var servicesDataProtection = new ServiceCollection(); ConfigureDataProtection(servicesDataProtection.AddDataProtection()); var serviceProviderDataProtection = servicesDataProtection.BuildServiceProvider(); // obtiene la interfaz IDataProtector para cifrar datos var dataProtector = serviceProviderDataProtection.GetRequiredService <IDataProtectionProvider>().CreateProtector (ProtectedJsonConfigurationProvider.DataProtectionPurpose); // cifra todas las etiquetas de token Protect:{<data>} de todos los archivos .json // (debe hacerse antes de leer la configuración) var encryptedFiles = dataProtector.ProtectFiles("."); // define la configuración de la aplicación y lee los archivos .json var configuration = new ConfigurationBuilder() .AddCommandLine(args) .AddProtectedJsonFile("appsettings.json", ConfigureDataProtection) .AddProtectedJsonFile($"appsettings.{Environment.GetEnvironmentVariable ("DOTNETCORE_ENVIRONMENT")}.json", ConfigureDataProtection) .AddEnvironmentVariables() .Build(); // define otros servicios DI: configura la clase de configuración // con tipos fuertes (debe hacerse después de leer la configuración) var services = new ServiceCollection(); services.Configure<AppSettings>(configuration); var serviceProvider = services.BuildServiceProvider(); // obtiene la clase de configuración de AppSettings con tipos fuertes var appSettings = serviceProvider.GetRequiredService <IOptions<AppSettings>>().Value; }
El código anterior es bastante simple y está comentado, si lo ejecutas en modo de depuración, coloca un punto de interrupción en la última línea donde se recupera la variable appSettings
de DI, notarás:
- los archivos appsettings.*json se han respaldado en un archivo .bak y sus etiquetas
Protect:{<data to encrypt>}
se han reemplazado por su versión cifrada (por ejemplo,Protected:{<encrypted data>}
) - mágica y automáticamente, la clase de configuración con tipos fuertes
appSettings
contiene los valores descifrados con el tipo de datos correcto, aunque las claves cifradas siempre se almacenan en el archivo JSON como cadenas.
Para usarlo, solo tuvimos que usar AddProtectedJsonFile en IConfigurationBuilder, pasar la configuración de la API de Protección de Datos y todo funciona perfectamente de manera transparente. Además, toda la descifrado se realiza en memoria y nada se almacena en disco por ninguna razón.
Detalles de Implementación
Explico aquí los puntos principales de la implementación:
IDataProtect.ProtectFiles
es el primer método de extensión que se llama y escanea todos los archivos JSON dentro del directorio proporcionado para buscar etiquetasProtect:{<data to encrypt>}
, cifra los datos adjuntos, realiza el reemplazo porProtected:{<encrypted data>}
y guarda el archivo después de haber creado una copia de seguridad opcional del archivo original con la extensión.bak
. Nuevamente, si no te gusta la expresión regular de tokenización predeterminada, puedes usar la tuya propia con la restricción de que debe extraer la subcadena<dato to encrypt>
en un grupo llamadoprotectData
.- El método de extensión
AddProtectedJsonFile
almacena los parámetros de entrada en un objetoProtectedJsonConfigurationSource
y lo pasa alIConfigurationBuilder
llamando al métodoAdd
. - La clase
ProtectedJsonConfigurationSource
deriva de la claseJsonConfigurationSource
estándar y agrega tres propiedades:ProtectedRegex
(después de verificar que la cadena regex proporcionada contiene un grupo llamadoprotectedData
),DataProtectionBuildAction
yServiceProvider
. El métodoBuild
reemplazado devuelve unProtectedJsonConfigurationProvider
pasándole la instancia deProtectedJsonConfigurationSource
. ProtectedJsonConfigurationProvider
es la clase responsable de la descifrado transparente. Básicamente:- configura otro proveedor de inyección de dependencias en el constructor (ver arriba la razón)
public ProtectedJsonConfigurationProvider (ProtectedJsonConfigurationSource source) : base(source){ // configure data protection if (source.DataProtectionBuildAction != null) { var services = new ServiceCollection(); source.DataProtectionBuildAction(services.AddDataProtection()); source.ServiceProvider = services.BuildServiceProvider(); } else if (source.ServiceProvider==null) throw new ArgumentNullException(nameof(source.ServiceProvider)); DataProtector = source.ServiceProvider.GetRequiredService <IDataProtectionProvider>().CreateProtector(DataProtectionPurpose);}
-
sobrescribe el método
Load
llamando primero el método correspondiente de la clase base (JsonConfigurationProvider
) para cargar y analizar el archivo JSON de entrada en la propiedadData
y luego recorre todas las claves, consulta y reemplaza el valor asociado para todas las etiquetas de tokenización usando el métodoReplace
de la regex después de haber descifrado su grupoprotectedData
(por ejemplo, <encrypted data>).public override void Load(){ base.Load(); var protectedSource = (ProtectedJsonConfigurationSource)Source; // decrypt needed values foreach (var kvp in Data) { if (!String.IsNullOrEmpty(kvp.Value)) Data[kvp.Key] = protectedSource.ProtectedRegex.Replace (kvp.Value, me => { return DataProtector.Unprotect(me.Groups["protectedData"].Value); }); }}
- configura otro proveedor de inyección de dependencias en el constructor (ver arriba la razón)
Puntos de Interés
Creo que la idea de especificar la etiqueta personalizada a través de una expresión regular es muy ingeniosa porque brinda a cada usuario la flexibilidad que necesitan para personalizar la etiqueta de tokenización. También lo he lanzado como un paquete NuGet en NuGet.Org
Historia
- V1.0 (20 de noviembre de 2023)
- Versión inicial
- V1.1 (21 de noviembre de 2023)
- Agregado el método de extensión
IDataProtect.ProtectFile
- Reemplazado el grupo con nombre de expresión regular de
protectionSection
porprotectedData
- Mejorada la legibilidad (¡había mucho básicamente!) y el código
- Agregado el método de extensión
Leave a Reply