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 o Host.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 y DOTNET_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> de Configure<wbr />App<wbr />Configuration donde puedes configurar la configuración de la aplicación a través del parámetro IConfigurationBuilder.
  • 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).

Imagen 1

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 implementa ProtectedJsonConfigurationSource, ProtectedJsonConfigurationProvider (y sus correspondientes ProtectedJsonStreamConfigurationProvider y ProtectedJsonStreamConfigurationSource basados en transmisiones) y los métodos de extensión para la interfaz IConfigurationBuilder (AddProtectedJsonFile y sus sobrecargas).
  • FDM.Extensions.Configuration.ProtectedJson.ConsoleTest: Este es un proyecto de consola que muestra cómo usar JsonProtector leyendo y analizando dos archivos de configuración personalizados y convirtiéndolos en una clase de tipo fuerte llamada AppSettings. 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 regular string que especifica la etiqueta de tokenización que encierra los datos cifrados; debe definir un grupo con nombre llamado protectedData. 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 nombre protectedData. 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 llamado protectedData.

  • serviceProvider: Esta es una interfaz IServiceProvider necesaria para instanciar IDataProtectionProvider de Data Protection API para descifrar los datos. Este parámetro es mutuamente excluyente con el siguiente.
  • dataProtectionConfigureAction: Esta es una Action<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 plano
  • PartiallyEncryptedConnectionString: Como su nombre indica, contiene una mezcla de texto plano y múltiples etiquetas de tokenización Protect:{<data to encrypt>}. En cada ejecución, estos tokens se cifran automáticamente y se reemplazan por el token Protected:{<encrypted data>} después de llamar al método de extensión IDataProtect.ProtectFiles.
  • FullyEncryptedConnectionString: Como su nombre indica, contiene un solo token Protect:{<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 una string utilizando una única etiqueta Protect:{<data to encrypt>}. ¡Espera un momento, ¿cómo es esto posible?

    Bueno, principalmente, todos los ConfigurationProviders convierten inicialmente cualquier ConfigurationSource en un Dictionary<String,String> en su método Load (consulta la propiedad Data de la clase base abstracta ConfigurationProvider del framework, el método Load 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 en Nullable: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 de strings (echa un vistazo a la clave DoubleArray).

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 etiquetas Protect:{<data to encrypt>}, cifra los datos adjuntos, realiza el reemplazo por Protected:{<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 llamado protectData.
  • El método de extensión AddProtectedJsonFile almacena los parámetros de entrada en un objeto ProtectedJsonConfigurationSource y lo pasa al IConfigurationBuilder llamando al método Add.
  • La clase ProtectedJsonConfigurationSource deriva de la clase JsonConfigurationSource estándar y agrega tres propiedades: ProtectedRegex (después de verificar que la cadena regex proporcionada contiene un grupo llamado protectedData), DataProtectionBuildAction y ServiceProvider. El método Build reemplazado devuelve un ProtectedJsonConfigurationProvider pasándole la instancia de ProtectedJsonConfigurationSource.
  • 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 propiedad Data y luego recorre todas las claves, consulta y reemplaza el valor asociado para todas las etiquetas de tokenización usando el método Replace de la regex después de haber descifrado su grupo protectedData (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);          });    }}

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 por protectedData 
    • Mejorada la legibilidad (¡había mucho básicamente!) y el código

Leave a Reply

Your email address will not be published. Required fields are marked *