Защищенный Json Интеграция настройки ASP.NET Core и защиты данных

Улучшенный поставщик конфигурации JSON, который позволяет частичное или полное шифрование значений в файле appsettings.json

Введение

ProtectedJson – это улучшенный поставщик конфигурации JSON, который позволяет частичное или полное шифрование значений конфигурации, хранящихся в файлах appsettings.json и полностью интегрируется в архитектуру ASP.NET Core. В основном, он реализует пользовательский ConfigurationSource и пользовательский ConfigurationProvider, который расшифровывает все зашифрованные данные, содержащиеся в пользовательской токенизации внутри значений JSON с использованием API Data Protection в ASP.NET Core.

Фон

Конфигурация ASP.NET – это стандартный способ .NET Core хранения данных конфигурации приложения через иерархические пары ключ-значение в различных источниках конфигурации (обычно в файлах JSON, а также через переменные среды, хранилища ключей, таблицы базы данных или любые другие пользовательские поставщики, которые вы хотите реализовать). В то время как .NET Framework использовал один источник (обычно XML-файл, который по своей сути был более подробным), .NET Core может использовать несколько упорядоченных источников конфигурации, которые “сливаются” и позволяют концепцию переопределения значения ключа в источнике конфигурации таким же значением, которое присутствует в последующем источнике конфигурации. Это полезно, потому что в разработке программного обеспечения обычно используется несколько сред (разработка, интеграция, пред-продакшн и продакшн), и каждая среда имеет свои собственные настройки (например, конечные точки API, строки подключения к базе данных, различные переменные конфигурации и т. д.). В .NET Core управление этим процессом является прямолинейным, на самом деле обычно есть два JSON-файла:

  • appsettings.json: который содержит общие параметры конфигурации для всех сред.
  • appsettings.<имя среды>.json: который содержит параметры конфигурации, специфические для конкретной среды.

Приложения ASP.NET Core обычно настраивают и запускают хост. Хост отвечает за запуск приложения, настройку внедрения зависимостей и фоновых служб, настройку регистрации, управление временем жизни и, очевидно, настройку конфигурации приложения. Это делается главным образом двумя способами:

  • Неявно, с использованием одного из методов, предоставленных фреймворком, таких как WebApplication.CreateBuilder или Host.CreateDefaultBuilder (обычно вызывается в файле исходного кода Program.cs), которые по сути делают следующее:
    • Чтение и разбор аргументов командной строки
    • Получение имени среды из переменной среды ASPNETCORE_ENVIRONMENT и DOTNET_ENVIRONMENT (устанавливаются как в переменных операционной системы, так и передаются непосредственно через командную строку с аргументом --environment).
    • Чтение и разбор двух JSON-конфигурационных файлов с именами appsettings.json и appsettings.<имя среды>.json.
    • Чтение и разбор переменных среды.
    • Вызов делегата Action<Microsoft.Extensions.Hosting.HostBuilderContext, Microsoft.Extensions.Configuration.IConfigurationBuilder> метода Configure<wbr />App<wbr />Configuration, где можно настроить конфигурацию приложения через параметр IConfigurationBuilder
  • Явно с помощью создания экземпляра класса ConfigurationBuilder и использования одного из методов расширения:
    • AddCommandLine: для запроса разбора параметров командной строки (путем использования -- или – или /)
    • AddJsonFile: для запроса разбора json-файла и указания, является ли он обязательным или необязательным, а также при необходимости автоматического перезагрузки при его изменении на файловой системе.
    • AddEnvironmentVariables: для запроса разбора переменных среды
    • и т. д.

По сути, каждый метод расширения Add<xxxx> добавляет ConfigurationSource для указания источника пар ключ-значение (командная строка, JSON-файл, переменные среды и т. д.) и соответствующий ConfigurationProvider, используемый для загрузки и разбора данных из источника в список Providers интерфейса IConfigurationRoot, который возвращается в результате вызова метода Build на классе ConfigurationBuilder, как показано на рисунке ниже.

(Внутри configuration.Providers у нас есть четыре источника: CommandLineConfigurationProvider, два ProtectedJsonConfigurationProvider для файлов appsettings.json и appsettings.<имя среды>.json, а также EnvironmentVariableConfigurationProvider).

Изображение 1

Как я уже писал, порядок вызова методов расширения Add<xxxx> важен, потому что когда класс IConfigurationRoot получает значение ключа, он использует метод GetConfiguration, который обходит список Providers в обратном порядке и пытается вернуть первый, который содержит запрошенный ключ, тем самым имитируя “слияние” всех источников конфигурации (порядок LIFO, последний вошел – первый вышел).

ProtectedJson представляет собой по сути библиотеку классов, которая определяет источник конфигурации с именем ProtectedJsonConfigurationSource, который указывает файл конфигурации и маркер токенизации, а также соответствующий поставщик конфигурации ProtectedJsonConfigurationProvider, используемый для разбора JSON-файла и расшифровки значений JSON, заключенных в маркер токенизации. Кроме того, она также предоставляет стандартные методы расширения для привязки их к интерфейсу IConfigurationBuilder (например, AddProtectedJsonFile).

Использование кода

Весь исходный код можно найти в моем репозитории на Github, код основан на .NET 6.0 и Visual Studio 2022. Внутри файлового решения есть два проекта:

  • FDM.Extensions.Configuration.ProtectedJson: это библиотека классов, которая реализует ProtectedJsonConfigurationSource, ProtectedJsonConfigurationProvider (а также соответствующие потоковые версии ProtectedJsonStreamConfigurationProvider и ProtectedJsonStreamConfigurationSource) и методы расширения для интерфейса IConfigurationBuilder (AddProtectedJsonFile и его перегрузки).
  • FDM.Extensions.Configuration.ProtectedJson.ConsoleTest: это консольный проект, который показывает, как использовать JsonProtector, считывая и разбирая два настроенных конфигурационных файла и преобразуя их в класс сильного типа с именем AppSettings. Расшифровка происходит безупречно и автоматически практически без строки кода, посмотрим, как.

Чтобы использовать ProtectedJson, вам нужно добавить любое количество JSON-файлов при помощи метода расширения AddProtectedJsonFile интерфейса IConfigurationBuilder, который принимает следующие параметры:

  • path: указывает путь и имя файла JSON (стандартный параметр)
  • optional: это логическое значение, указывающее, является ли файл JSON обязательным или необязательным (стандартный параметр)
  • reloadOnChange: это логическое значение, которое указывает, должен ли быть автоматически перезагружен JSON-файл (и конфигурация), когда указанный файл изменяется на диске (стандартный параметр).
  • protectedRegexString: это регулярное выражение типа string, которое указывает маркер токенизации, заключающий зашифрованные данные. Оно должно определять именованную группу с именем protectedData. По умолчанию этот параметр принимает значение:
    public const string DefaultProtectedRegexString = "Protected:{(?<protectedData>.+?)}";

    Вышеуказанное регулярное выражение осуществляет жадный поиск (чтобы обнаружить все вхождения внутри значения JSON) для любой string, совпадающей с шаблоном 'Protected:{<шифрованные данные>}', и извлекает подстроку <шифрованные данные>, сохраняя ее в группе с именем protectedData. Если вам не нравится такая токенизация, вы можете заменить ее на любую другую, предпочитаемую вами, создав регулярное выражение с ограничением, что оно извлекает подстроку <шифрованные данные> в группу с именем protectedData.

  • serviceProvider: это интерфейс IServiceProvider, необходимый для создания экземпляра IDataProtectionProvider API защиты данных для расшифровки данных. Этот параметр взаимоисключающий с предыдущим.
  • dataProtectionConfigureAction: это Action<IDataProtectionBuilder>, который используется для настройки API защиты данных в стандартной версии NET Core. И снова, этот параметр взаимоисключающий с предыдущим.

Последние два параметра немного неудобны, потому что они представляют переконфигурацию другой инъекции зависимостей для создания поставщика IDataProtectionProvider, необходимого для расшифровки данных.

Фактически, в стандартном приложении NET Core, обычно инъекция зависимостей настраивается после чтения и разбора файла конфигурации (таким образом, все источники и поставщики конфигурации не используют DI), но в этом случае я был вынужден делать это, так как единственный способ получить доступ к API Защиты данных – через DI. Кроме того, при настройке инъекции зависимостей, обычно разобранная конфигурация привязывается к строго типизированному классу с помощью services.Configure<<строго типизированный класс настроек>>(configuration), поэтому это как собака, гоняющаяся за своим хвостом (для расшифровки конфигурации вам нужен DI, для настройки DI вам нужна разобранная конфигурация, чтобы привязать ее к строго типизированному классу). Единственное решение, на которое я пришел пока, это перенастройка второго DI IServiceProvider только для API Защиты данных и использование его внутри ProtectedJsonConfigurationProvider. Для настройки второго DI IServiceProvider у вас есть два варианта: вы можете создать его самостоятельно (создав ServiceCollection, вызвав AddDataProtection и передав его в AddProtectedJsonFile) или позволить ProtectedJsonConfigurationProvider создать его, передав dataProtectionConfigureAction в AddProtectedJsonFile. В консольном приложении, чтобы избежать дублирования кода, настройка API Защиты данных выполняется внутри общего частного метода ConfigureDataProtection с такой реализацией:

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

Здесь я выбрал использовать AES 256 симметричное шифрование с HMAC SHA256 как функцию цифровой подписи. Кроме того, я прошу сохранить все метаданные шифрования (ключи, iv, ключ алгоритма хэша) в файл XML в папке Keys консольного приложения (обратите внимание, что все эти API по умолчанию предоставляются API Защиты данных). Поэтому, когда вы запускаете приложение в первый раз, API Защиты данных автоматически создает ключ шифрования и сохраняет его в папке Keys, в последующих запусках он загружает данные ключа из этого XML-файла. Однако эта конфигурация не является наилучшим подходом с точки зрения безопасности, так как метаданные хранятся в виде обычного текста. Если вы используете Windows, вы можете удалить расширение метода PersistKeysToFileSystem, и в этом случае метаданные будут зашифрованы с использованием другого ключа, хранящегося в безопасном месте на вашем компьютере. Но у меня нет представления о том, как API Защиты данных обрабатывает это в Linux.

В файлах appsetting.json и appsettings.development.json определены стандартные ключ-значение в иерархической форме, чтобы продемонстрировать функцию слияния ASP.NET Core Configuration и также использование зашифрованных значений.

Если вы посмотрите на раздел ConnectionStrings файла appsetting.json, вы увидите три ключа:

  • PlainTextConnectionString: Как уже говорит название, он содержит обычную строку подключения
  • PartiallyEncryptedConnectionString: Как уже говорит название, он содержит смесь обычного текста и нескольких токенизирующих тегов Protect:{<данные для шифрования>}. При каждом запуске эти токены автоматически шифруются и заменяются на токен Protected:{<зашифрованные данные>} после вызова метода-расширения IDataProtect.ProtectFiles.
  • FullyEncryptedConnectionString: Как уже говорит название, он содержит единственный токен Protect:{<данные для шифрования>} , охватывающий всю строку подключения, который полностью шифруется после первого запуска.

Если вы посмотрите на раздел Nullable файла appsetting.development.json, вы найдете некоторые интересные ключи:

  • Int, DateTime, Double, Bool: Эти ключи соответственно содержат целое число, дату и время, число с плавающей точкой и логическое значение, но все они хранятся как string с использованием единственного токена Protect:{<данные для шифрования>}. Подождите, как это возможно?

    Ну, в основном, все ConfigurationProviders вначале конвертируют любой ConfigurationSource в Dictionary<String,String> в их методе Load (см. свойство Data абстрактного базового класса ConfigurationProvider фреймворка, метод Load также выравнивает все иерархические пути к ключу в виде строка, разделенную двоеточием, так что, например, Nullable->Int становится Nullable:Int). Только после этого этот словарь преобразуется и привязывается к строго типизированному классу.

    Процесс дешифровки ProtectedJsonConfigurationProvider происходит в середине, поэтому он прозрачен для пользователя и, кроме того, доступен для любого простого переменного типа (DateTime, bool и т. д.). В настоящее время полное шифрование всего массива не поддерживается, но вы можете зашифровать отдельный элемент, преобразовав массив в массив string (посмотрите на ключ DoubleArray).

Основной код:

public static void Main(string[] args){    // определение услуг DI: Data Protection API    var servicesDataProtection = new ServiceCollection();    ConfigureDataProtection(servicesDataProtection.AddDataProtection());    var serviceProviderDataProtection = servicesDataProtection.BuildServiceProvider();    // получение интерфейса IDataProtector для шифрования данных    var dataProtector = serviceProviderDataProtection.GetRequiredService                        <IDataProtectionProvider>().CreateProtector                        (ProtectedJsonConfigurationProvider.DataProtectionPurpose);    // шифрование всех тегов Protect:{<данные>} во всех .json файлах     // (должно быть сделано перед чтением конфигурации)    var encryptedFiles = dataProtector.ProtectFiles(".");    // определение конфигурации приложения и чтение .json файлов    var configuration = new ConfigurationBuilder()            .AddCommandLine(args)            .AddProtectedJsonFile("appsettings.json", ConfigureDataProtection)            .AddProtectedJsonFile($"appsettings.{Environment.GetEnvironmentVariable                   ("DOTNETCORE_ENVIRONMENT")}.json", ConfigureDataProtection)            .AddEnvironmentVariables()            .Build();    // определение других услуг DI: настройка класса конфигурации AppSettings     // (должно быть сделано после чтения конфигурации)    var services = new ServiceCollection();    services.Configure<AppSettings>(configuration);    var serviceProvider = services.BuildServiceProvider();    // получение класса конфигурации AppSettings с указанным типом    var appSettings = serviceProvider.GetRequiredService                      <IOptions<AppSettings>>().Value;    }

Вышеуказанный код довольно прост и пояснен, если вы запустите его в режиме отладки и поставите точку останова на последней строке, где переменная appSettings извлекается из DI, вы заметите:

  • файлы appsettings.*json были скопированы в файл .bak и у токенов Protect:{<data to encrypt>} были заменены на их зашифрованные версии (например, Protected:{<encrypted data>})
  • волшебным образом и автоматически класс appSettings с типизированной настройкой содержит расшифрованные значения с правильным типом данных, даже если зашифрованные ключи всегда хранятся в JSON файле как строки.

Для использования этого, нужно всего лишь использовать AddProtectedJsonFile на IConfigurationBuilder, передать конфигурацию Data Protection API и все работает безупречно в прозрачном режиме. Кроме того, вся расшифровка происходит в памяти и ничего не сохраняется на диске по какой-либо причине.

Подробности реализации

Я объясню основные моменты реализации здесь:

  • IDataProtect.ProtectFiles – первый вызываемый метод расширения, который сканирует все JSON файлы в указанной директории на наличие токенов Protect:{<data to encrypt>}, шифрует вложенные данные, производит замену на Protected:{<шифрованные данные>} и сохраняет файл после создания необязательной резервной копии оригинального файла с расширением .bak. Опять же, если вам не нравится стандартное регулярное выражение для токенизации, вы можете передать свое, с условием, что оно должно извлекать подстроку <data to encrypt> в группе с именем protectData.
  • Метод расширения AddProtectedJsonFile сохраняет входные параметры в объекте ProtectedJsonConfigurationSource и передает его в IConfigurationBuilder, вызывая метод Add.
  • Класс ProtectedJsonConfigurationSource производный от стандартного JsonConfigurationSource и добавляет три свойства: ProtectedRegex (после проверки предоставленной строки регулярного выражения на наличие группы с именем protectedData), DataProtectionBuildAction и ServiceProvider. Переопределенный метод Build возвращает экземпляр ProtectedJsonConfigurationProvider, передавая в него экземпляр ProtectedJsonConfigurationSource.
  • ProtectedJsonConfigurationProvider – класс, отвечающий за прозрачное расшифрование. В основном:
    • в конструкторе устанавливается другой провайдер внедрения зависимостей (см. выше по причине)
      public ProtectedJsonConfigurationProvider      (ProtectedJsonConfigurationSource source) : base(source){    // настройка 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);}
    • переопределяется метод Load, сначала вызывая метод соответствующего базового класса (JsonConfigurationProvider) для загрузки и разбора входного JSON файла в свойство Data, а затем циклически проходя по всем ключам, запрашивая и заменяя связанное значение для всех токенизационных тегов, используя метод Replace из regex после расшифровки его группы protectedData (например, <зашифрованные данные>).

      public override void Load(){    base.Load();    var protectedSource = (ProtectedJsonConfigurationSource)Source;    // расшифровываем необходимые значения    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);          });    }}

Интересные места

Я думаю, что идея указания пользовательского тега через регулярное выражение очень остроумна, потому что это дает каждому пользователю гибкость, которая необходима для настройки токенизации тега. Я также выпустил его в виде NuGet пакета на NuGet.Org

История

  • V1.0 (20 ноября 2023 г.)
    • Начальная версия
  • V1.1 (21 ноября 2023 г.)
    • Добавлен метод расширения IDataProtect.ProtectFile
    • Заменено именованную группу регулярного выражения с protectionSection на protectedData
    • Улучшена читаемость (было много вообще!) и кода

Leave a Reply

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