From 043d378a157069c78863d2b9695de2884fb81ad1 Mon Sep 17 00:00:00 2001 From: vnugent Date: Mon, 12 Jun 2023 19:15:50 -0400 Subject: Auto type init, async/lazy loading --- .../src/AssemblyLoader.cs | 20 +- .../src/ConfigurationExtensions.cs | 40 ++ .../src/IAsyncLazy.cs | 148 ++++++ .../src/LoadingExtensions.cs | 175 ++++++- .../src/ManagedPasswordHashing.cs | 44 +- .../src/PrivateKey.cs | 115 ----- .../src/SecretResult.cs | 61 --- .../src/Secrets/ISecretResult.cs | 39 ++ .../src/Secrets/PrivateKey.cs | 115 +++++ .../src/Secrets/SecretResult.cs | 60 +++ .../src/Secrets/VaultSecrets.cs | 572 +++++++++++++++++++++ .../src/VaultSecrets.cs | 498 ------------------ 12 files changed, 1150 insertions(+), 737 deletions(-) create mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/IAsyncLazy.cs delete mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs delete mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs create mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/Secrets/ISecretResult.cs create mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/Secrets/PrivateKey.cs create mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/Secrets/SecretResult.cs create mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/Secrets/VaultSecrets.cs delete mode 100644 lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs (limited to 'lib/VNLib.Plugins.Extensions.Loading') diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs b/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs index 5de6103..e01b32d 100644 --- a/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs +++ b/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs @@ -159,29 +159,19 @@ namespace VNLib.Plugins.Extensions.Loading /// /// The name of the assmbly within the current plugin directory /// The plugin unload token - /// Explicitly set an assembly load context to load the requested assembly into + /// The assembly load context to load the assmbly into /// - internal static AssemblyLoader Load(string assemblyName, AssemblyLoadContext? explicitContext, CancellationToken unloadToken) + internal static AssemblyLoader Load(string assemblyName, AssemblyLoadContext loadContext, CancellationToken unloadToken) { + _ = loadContext ?? throw new ArgumentNullException(nameof(loadContext)); + //Make sure the file exists if (!FileOperations.FileExists(assemblyName)) { throw new FileNotFoundException($"The desired assembly {assemblyName} could not be found at the file path"); } - - if(explicitContext == null) - { - /* - * Dynamic assemblies are loaded directly to the exe assembly context. - * This should always be the plugin isolated context. - */ - - Assembly executingAsm = Assembly.GetExecutingAssembly(); - explicitContext = AssemblyLoadContext.GetLoadContext(executingAsm) ?? throw new InvalidOperationException("Could not get default assembly load context"); - } - - return new(assemblyName, explicitContext, unloadToken); + return new(assemblyName, loadContext, unloadToken); } } diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs b/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs index 190c153..fbe8d48 100644 --- a/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs +++ b/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs @@ -23,11 +23,14 @@ */ using System; +using System.IO; using System.Text.Json; using System.Reflection; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using VNLib.Utils.Extensions; + namespace VNLib.Plugins.Extensions.Loading { /// @@ -66,6 +69,9 @@ namespace VNLib.Plugins.Extensions.Loading { public const string S3_CONFIG = "s3_config"; public const string S3_SECRET_KEY = "s3_secret"; + public const string PLUGIN_ASSET_KEY = "assets"; + public const string PLUGINS_HOST_KEY = "plugins"; + public const string PLUGIN_PATH_KEY = "path"; /// /// Retrieves a top level configuration dictionary of elements for the specified type. @@ -356,5 +362,39 @@ namespace VNLib.Plugins.Extensions.Loading IConfigScope? s3conf = plugin.TryGetConfig(S3_CONFIG); return s3conf?.Deserialze(); } + + /// + /// Trys to get the optional assets directory from the plugin configuration + /// + /// + /// The absolute path to the assets directory if defined, null otherwise + public static string? GetAssetsPath(this PluginBase plugin) + { + //Get global plugin config element + IConfigScope config = plugin.GetConfig(PLUGINS_HOST_KEY); + + //Try to get the assets path if its defined + string? assetsPath = config.GetPropString(PLUGIN_ASSET_KEY); + + //Try to get the full path for the assets if we can + return assetsPath != null ? Path.GetFullPath(assetsPath) : null; + } + + /// + /// Gets the absolute path to the plugins directory as defined in the host configuration + /// + /// + /// The absolute path to the directory containing all plugins + public static string GetPluginsPath(this PluginBase plugin) + { + //Get global plugin config element + IConfigScope config = plugin.GetConfig(PLUGINS_HOST_KEY); + + //Get the plugins path or throw because it should ALWAYS be defined if this method is called + string pluginsPath = config[PLUGIN_PATH_KEY].GetString()!; + + //Get absolute path + return Path.GetFullPath(pluginsPath); + } } } diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/IAsyncLazy.cs b/lib/VNLib.Plugins.Extensions.Loading/src/IAsyncLazy.cs new file mode 100644 index 0000000..98e0ebe --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/IAsyncLazy.cs @@ -0,0 +1,148 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: IAsyncLazy.cs +* +* IAsyncLazy.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + + +using System; +using System.Threading.Tasks; +using System.Runtime.CompilerServices; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// + /// Represents an asynchronous lazy operation. with non-blocking access to the target value. + /// + /// The result type + public interface IAsyncLazy + { + /// + /// Gets a value indicating whether the asynchronous operation has completed. + /// + bool Completed { get; } + + /// + /// Gets a task that represents the asynchronous operation. + /// + /// + TaskAwaiter GetAwaiter(); + + /// + /// Gets the target value of the asynchronous operation without blocking. + /// If the operation failed, throws an exception that caused the failure. + /// If the operation has not completed, throws an exception. + /// + T Value { get; } + } + + /// + /// Extension methods for + /// + public static class AsyncLazyExtensions + { + /// + /// Gets an wrapper for the specified + /// + /// + /// + /// The async operation task wrapper + public static IAsyncLazy AsLazy(this Task task) => new AsyncLazy(task); + + /// + /// Tranforms one lazy operation into another using the specified handler + /// + /// + /// The resultant type + /// + /// The function that will peform the transformation of the lazy result + /// A new that returns the transformed type + public static IAsyncLazy Transform(this IAsyncLazy lazy, Func handler) + { + _ = lazy ?? throw new ArgumentNullException(nameof(lazy)); + _ = handler ?? throw new ArgumentNullException(nameof(handler)); + + //Await the lazy task, then pass the result to the handler + static async Task OnResult(IAsyncLazy lazy, Func cb) + { + T result = await lazy; + return cb(result); + } + + return OnResult(lazy, handler).AsLazy(); + } + +#nullable disable + + private sealed class AsyncLazy : IAsyncLazy + { + private readonly Task _task; + + private T _result; + + public AsyncLazy(Task task) + { + _task = task ?? throw new ArgumentNullException(nameof(task)); + _ = task.ContinueWith(SetResult, TaskScheduler.Default); + } + + /// + public bool Completed => _task.IsCompleted; + + /// + public T Value + { + get + { + if (_task.IsCompletedSuccessfully) + { + return _result; + } + else if(_task.IsFaulted) + { + //Compress and raise exception from result + return _task.GetAwaiter().GetResult(); + } + else + { + throw new InvalidOperationException("The asynchronous operation has not completed."); + } + } + } + + /* + * Only set the result if the task completed successfully. + */ + private void SetResult(Task task) + { + if (task.IsCompletedSuccessfully) + { + _result = task.Result; + } + } + + /// + public TaskAwaiter GetAwaiter() => _task.GetAwaiter(); + } +#nullable enable + + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs b/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs index 5511398..8f7dee8 100644 --- a/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs +++ b/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs @@ -37,6 +37,50 @@ using VNLib.Utils.Extensions; namespace VNLib.Plugins.Extensions.Loading { + /// + /// Base class for concrete type loading exceptions. Raised when searching + /// for a concrete type fails. + /// + public class ConcreteTypeException : TypeLoadException + { + public ConcreteTypeException() : base() + { } + + public ConcreteTypeException(string? message) : base(message) + { } + + public ConcreteTypeException(string? message, Exception? innerException) : base(message, innerException) + { } + } + + /// + /// Raised when a concrete type is found but is ambiguous because more than one + /// type implements the desired abstract type. + /// + public sealed class ConcreteTypeAmbiguousMatchException : ConcreteTypeException + { + public ConcreteTypeAmbiguousMatchException(string message) : base(message) + { } + + public ConcreteTypeAmbiguousMatchException(string message, Exception innerException) : base(message, innerException) + { } + + public ConcreteTypeAmbiguousMatchException() + { } + } + + /// + /// The requested concrete type was not found in the assembly + /// + public sealed class ConcreteTypeNotFoundException : ConcreteTypeException + { + public ConcreteTypeNotFoundException(string message) : base(message) + { } + public ConcreteTypeNotFoundException(string message, Exception innerException) : base(message, innerException) + { } + public ConcreteTypeNotFoundException() + { } + } /// /// Provides common loading (and unloading when required) extensions for plugins @@ -47,7 +91,7 @@ namespace VNLib.Plugins.Extensions.Loading /// A key in the 'plugins' configuration object that specifies /// an asset search directory /// - public const string PLUGIN_ASSET_KEY = "assets"; + public const string DEBUG_CONFIG_KEY = "debug"; public const string SECRETS_CONFIG_KEY = "secrets"; public const string PASSWORD_HASHING_KEY = "passwords"; @@ -121,31 +165,79 @@ namespace VNLib.Plugins.Extensions.Loading { plugin.ThrowIfUnloaded(); _ = assemblyName ?? throw new ArgumentNullException(nameof(assemblyName)); - - //get plugin directory from config - IConfigScope config = plugin.GetConfig("plugins"); + /* * Allow an assets directory to limit the scope of the search for the desired * assembly, otherwise search all plugins directories */ - - string? assetDir = config.GetPropString(PLUGIN_ASSET_KEY); - assetDir ??= config["path"].GetString(); + + string? assetDir = plugin.GetAssetsPath(); + assetDir ??= plugin.GetPluginsPath(); /* * This should never happen since this method can only be called from a * plugin context, which means this path was used to load the current plugin */ - _ = assetDir ?? throw new ArgumentNullException(PLUGIN_ASSET_KEY, "No plugin path is defined for the current host configuration, this is likely a bug"); + _ = assetDir ?? throw new ArgumentNullException(ConfigurationExtensions.PLUGIN_ASSET_KEY, "No plugin path is defined for the current host configuration, this is likely a bug"); //Get the first file that matches the search file string? asmFile = Directory.EnumerateFiles(assetDir, assemblyName, dirSearchOption).FirstOrDefault(); _ = asmFile ?? throw new FileNotFoundException($"Failed to load custom assembly {assemblyName} from plugin directory"); - + + //Get the plugin's load context if not explicitly supplied + explictAlc ??= GetPluginLoadContext(); + //Load the assembly return AssemblyLoader.Load(asmFile, explictAlc, plugin.UnloadToken); - } + } + + /// + /// Gets the current plugin's . + /// + /// + /// + public static AssemblyLoadContext GetPluginLoadContext() + { + /* + * Since this library should only be used in a plugin context, the executing assembly + * will be loaded into the plugin's isolated load context. So we can get the load + * context for the executing assembly and use that as the plugin's load context. + */ + + Assembly executingAsm = Assembly.GetExecutingAssembly(); + return AssemblyLoadContext.GetLoadContext(executingAsm) ?? throw new InvalidOperationException("Could not get plugin's assembly load context"); + } + + /// + /// Gets a single type implemenation of the abstract type from the current assembly. If multiple + /// concrete types are found, an exception is raised, if no concrete types are found, an exception + /// is raised. + /// + /// The abstract type to get the concrete type from + /// The concrete type if found + /// + /// + public static Type GetTypeImplFromCurrentAssembly(Type abstractType) + { + //Get all types from the current assembly that implement the abstract type + Assembly executingAsm = Assembly.GetExecutingAssembly(); + Type[] concreteTypes = executingAsm.GetTypes().Where(t => !t.IsAbstract && abstractType.IsAssignableFrom(t)).ToArray(); + + if(concreteTypes.Length == 0) + { + throw new ConcreteTypeNotFoundException($"Failed to load implemenation of abstract type {abstractType} because no concrete implementations were found in this assembly"); + } + + if(concreteTypes.Length > 1) + { + throw new ConcreteTypeAmbiguousMatchException( + $"Failed to load implemenation of abstract type {abstractType} because multiple concrete implementations were found in this assembly"); + } + + //Get the only concrete type + return concreteTypes[0]; + } /// /// Determintes if the current plugin config has a debug propety set @@ -285,11 +377,13 @@ namespace VNLib.Plugins.Extensions.Loading /// /// /// + /// + /// public static T GetOrCreateSingleton(this PluginBase plugin) { //Add service to service continer return GetOrCreateSingleton(plugin, CreateService); - } + } /// /// @@ -311,6 +405,8 @@ namespace VNLib.Plugins.Extensions.Loading /// /// /// + /// + /// public static T GetOrCreateSingleton(this PluginBase plugin, string configName) { //Add service to service continer @@ -357,6 +453,8 @@ namespace VNLib.Plugins.Extensions.Loading /// /// /// + /// + /// public static T CreateService(this PluginBase plugin) { if (plugin.HasConfigForType()) @@ -390,6 +488,8 @@ namespace VNLib.Plugins.Extensions.Loading /// /// /// + /// + /// public static T CreateService(this PluginBase plugin, string configName) { IConfigScope config = plugin.GetConfig(configName); @@ -416,30 +516,67 @@ namespace VNLib.Plugins.Extensions.Loading /// /// /// + /// + /// public static T CreateService(this PluginBase plugin, IConfigScope? config) { + return (T)CreateService(plugin, typeof(T), config); + } + + /// + /// + /// Creates and configures a new instance of the desired type, with the specified configuration scope + /// + /// + /// If the type derrives the + /// method is called once when the instance is loaded, and observed on the plugin scheduler. + /// + /// + /// If the type derrives the + /// method is called once when the instance is loaded, and observed on the plugin scheduler. + /// + /// + /// + /// The service type to instantiate + /// The configuration scope to pass directly to the new instance + /// The a new instance configured service + /// + /// + /// + /// + /// + public static object CreateService(this PluginBase plugin, Type serviceType, IConfigScope? config) + { + _ = plugin ?? throw new ArgumentNullException(nameof(plugin)); + _ = serviceType ?? throw new ArgumentNullException(nameof(serviceType)); + plugin.ThrowIfUnloaded(); - Type serviceType = typeof(T); + //The requested sesrvice is not a class, so see if we can find a default implementation in assembly + if (serviceType.IsAbstract || serviceType.IsInterface) + { + //Overwrite the service type with the default implementation + serviceType = GetTypeImplFromCurrentAssembly(serviceType); + } - T service; + object service; //Determin configuration requirments if (ConfigurationExtensions.ConfigurationRequired(serviceType) || config != null) { - if(config == null) + if (config == null) { ConfigurationExtensions.ThrowConfigNotFoundForType(serviceType); } //Get the constructor for required or available config - ConstructorInfo? constructor = serviceType.GetConstructor(new Type[] { typeof(PluginBase), typeof(IConfigScope) }); + ConstructorInfo? constructor = serviceType.GetConstructor(new Type[] { typeof(PluginBase), typeof(IConfigScope) }); //Make sure the constructor exists _ = constructor ?? throw new EntryPointNotFoundException($"No constructor found for {serviceType.Name}"); //Call constructore - service = (T)constructor.Invoke(new object[2] { plugin, config }); + service = constructor.Invoke(new object[2] { plugin, config }); } else { @@ -450,8 +587,8 @@ namespace VNLib.Plugins.Extensions.Loading _ = constructor ?? throw new EntryPointNotFoundException($"No constructor found for {serviceType.Name}"); //Call constructore - service = (T)constructor.Invoke(new object[1] { plugin }); - } + service = constructor.Invoke(new object[1] { plugin }); + } Task? loading = null; @@ -475,7 +612,7 @@ namespace VNLib.Plugins.Extensions.Loading #pragma warning restore CA5394 // Do not use insecure randomness //If the instances supports async loading, dont start work until its loaded - if(loading != null) + if (loading != null) { _ = loading.ContinueWith(t => ObserveWork(plugin, bw, randomDelay), TaskScheduler.Default); } diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/ManagedPasswordHashing.cs b/lib/VNLib.Plugins.Extensions.Loading/src/ManagedPasswordHashing.cs index 522bfae..3489789 100644 --- a/lib/VNLib.Plugins.Extensions.Loading/src/ManagedPasswordHashing.cs +++ b/lib/VNLib.Plugins.Extensions.Loading/src/ManagedPasswordHashing.cs @@ -25,7 +25,6 @@ using System; using System.Linq; using System.Text.Json; -using System.Threading.Tasks; using System.Collections.Generic; using VNLib.Utils; @@ -121,10 +120,9 @@ namespace VNLib.Plugins.Extensions.Loading public bool Verify(ReadOnlySpan passHash, ReadOnlySpan password) => _loader.Resource.Verify(passHash, password); } - private sealed class SecretProvider : VnDisposeable, ISecretProvider, IAsyncConfigurable + private sealed class SecretProvider : VnDisposeable, ISecretProvider { - private byte[]? _pepper; - private Exception? _error; + private readonly IAsyncLazy _pepper; public SecretProvider(PluginBase plugin, IConfigScope config) { @@ -146,11 +144,19 @@ namespace VNLib.Plugins.Extensions.Loading { Passwords = new(this); } + + //Get the pepper from secret storage + _pepper = plugin.GetSecretAsync(LoadingExtensions.PASSWORD_HASHING_KEY) + .ToLazy(static sr => sr.GetFromBase64()); } public SecretProvider(PluginBase plugin) { Passwords = new(this); + + //Get the pepper from secret storage + _pepper = plugin.GetSecretAsync(LoadingExtensions.PASSWORD_HASHING_KEY) + .ToLazy(static sr => sr.GetFromBase64()); } @@ -162,7 +168,7 @@ namespace VNLib.Plugins.Extensions.Loading get { Check(); - return _pepper!.Length; + return _pepper.Value.Length; } } @@ -170,41 +176,21 @@ namespace VNLib.Plugins.Extensions.Loading { Check(); //Coppy pepper to buffer - _pepper.CopyTo(buffer); + _pepper.Value.CopyTo(buffer); //Return pepper length - return _pepper!.Length; + return _pepper.Value.Length; } protected override void Check() { base.Check(); - if (_error != null) - { - throw _error; - } + _ = _pepper.Value; } protected override void Free() { //Clear the pepper if set - MemoryUtil.InitializeBlock(_pepper.AsSpan()); - } - - public async Task ConfigureServiceAsync(PluginBase plugin) - { - try - { - //Get the pepper from secret storage - _pepper = await plugin.TryGetSecretAsync(LoadingExtensions.PASSWORD_HASHING_KEY).ToBase64Bytes(); - } - catch (Exception ex) - { - //Store exception for re-propagation - _error = ex; - - //Propagate exception to system - throw; - } + MemoryUtil.InitializeBlock(_pepper.Value.AsSpan()); } } } diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs b/lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs deleted file mode 100644 index 2e5fb7f..0000000 --- a/lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs +++ /dev/null @@ -1,115 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Extensions.Loading -* File: PrivateKey.cs -* -* PrivateKey.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify -* it under the terms of the GNU Affero General Public License as -* published by the Free Software Foundation, either version 3 of the -* License, or (at your option) any later version. -* -* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU Affero General Public License for more details. -* -* You should have received a copy of the GNU Affero General Public License -* along with this program. If not, see https://www.gnu.org/licenses/. -*/ - -using System; -using System.Text; -using System.Security.Cryptography; - -using VNLib.Utils; -using VNLib.Utils.Memory; -using VNLib.Utils.Extensions; - -namespace VNLib.Plugins.Extensions.Loading -{ - /// - /// A container for a PKSC#8 encoed private key - /// - public sealed class PrivateKey : VnDisposeable - { - private readonly byte[] _utf8RawData; - - /// - /// Decodes the PKCS#8 encoded private key from a secret, as an EC private key - /// and recovers the ECDsa algorithm from the key - /// - /// The algoritm from the private key - /// - /// - public ECDsa GetECDsa() - { - //Alloc buffer - using IMemoryHandle buffer = MemoryUtil.SafeAlloc(_utf8RawData.Length); - - //Get base64 bytes from utf8 - ERRNO count = VnEncoding.Base64UrlDecode(_utf8RawData, buffer.Span); - - //Parse the private key - ECDsa alg = ECDsa.Create(); - - alg.ImportPkcs8PrivateKey(buffer.Span[..(int)count], out _); - - //Wipe the buffer - MemoryUtil.InitializeBlock(buffer.Span); - - return alg; - } - - /// - /// Decodes the PKCS#8 encoded private key from a secret, as an RSA private key - /// - /// The algorithm from the private key - /// - /// - public RSA GetRSA() - { - //Alloc buffer - using IMemoryHandle buffer = MemoryUtil.SafeAlloc(_utf8RawData.Length); - - //Get base64 bytes from utf8 - ERRNO count = VnEncoding.Base64UrlDecode(_utf8RawData, buffer.Span); - - //Parse the private key - RSA alg = RSA.Create(); - - alg.ImportPkcs8PrivateKey(buffer.Span[..(int)count], out _); - - //Wipe the buffer - MemoryUtil.InitializeBlock(buffer.Span); - - return alg; - } - - internal PrivateKey(SecretResult secret) - { - //Alloc and get utf8 - byte[] buffer = new byte[secret.Result.Length]; - - int count = Encoding.UTF8.GetBytes(secret.Result, buffer); - - //Verify length - if(count != buffer.Length) - { - throw new FormatException("UTF8 deocde failed"); - } - - //Store - _utf8RawData = buffer; - } - - protected override void Free() - { - MemoryUtil.InitializeBlock(_utf8RawData.AsSpan()); - } - } -} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs b/lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs deleted file mode 100644 index 6c1c5f8..0000000 --- a/lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs +++ /dev/null @@ -1,61 +0,0 @@ -/* -* Copyright (c) 2022 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Extensions.Loading -* File: SecretResult.cs -* -* SecretResult.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify -* it under the terms of the GNU Affero General Public License as -* published by the Free Software Foundation, either version 3 of the -* License, or (at your option) any later version. -* -* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU Affero General Public License for more details. -* -* You should have received a copy of the GNU Affero General Public License -* along with this program. If not, see https://www.gnu.org/licenses/. -*/ - -using System; - -using VNLib.Utils; -using VNLib.Utils.Extensions; -using VNLib.Utils.Memory; - -namespace VNLib.Plugins.Extensions.Loading -{ - /// - /// The result of a secret fetch operation - /// - public sealed class SecretResult : VnDisposeable - { - private readonly char[] _secretChars; - - /// - /// The protected raw result value - /// - public ReadOnlySpan Result => _secretChars; - - - internal SecretResult(ReadOnlySpan value) => _secretChars = value.ToArray(); - - /// - protected override void Free() - { - MemoryUtil.InitializeBlock(_secretChars.AsSpan()); - } - - internal static SecretResult ToSecret(string? result) - { - SecretResult res = new(result.AsSpan()); - MemoryUtil.UnsafeZeroMemory(result); - return res; - } - } -} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/ISecretResult.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/ISecretResult.cs new file mode 100644 index 0000000..b3c8737 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/ISecretResult.cs @@ -0,0 +1,39 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: ISecretResult.cs +* +* ISecretResult.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// + /// The result of a secret fetch operation + /// + public interface ISecretResult : IDisposable + { + /// + /// The protected raw result value + /// + ReadOnlySpan Result { get; } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/PrivateKey.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/PrivateKey.cs new file mode 100644 index 0000000..08637cd --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/PrivateKey.cs @@ -0,0 +1,115 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: PrivateKey.cs +* +* PrivateKey.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Text; +using System.Security.Cryptography; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// + /// A container for a PKSC#8 encoed private key + /// + public sealed class PrivateKey : VnDisposeable + { + private readonly byte[] _utf8RawData; + + /// + /// Decodes the PKCS#8 encoded private key from a secret, as an EC private key + /// and recovers the ECDsa algorithm from the key + /// + /// The algoritm from the private key + /// + /// + public ECDsa GetECDsa() + { + //Alloc buffer + using IMemoryHandle buffer = MemoryUtil.SafeAlloc(_utf8RawData.Length); + + //Get base64 bytes from utf8 + ERRNO count = VnEncoding.Base64UrlDecode(_utf8RawData, buffer.Span); + + //Parse the private key + ECDsa alg = ECDsa.Create(); + + alg.ImportPkcs8PrivateKey(buffer.Span[..(int)count], out _); + + //Wipe the buffer + MemoryUtil.InitializeBlock(buffer.Span); + + return alg; + } + + /// + /// Decodes the PKCS#8 encoded private key from a secret, as an RSA private key + /// + /// The algorithm from the private key + /// + /// + public RSA GetRSA() + { + //Alloc buffer + using IMemoryHandle buffer = MemoryUtil.SafeAlloc(_utf8RawData.Length); + + //Get base64 bytes from utf8 + ERRNO count = VnEncoding.Base64UrlDecode(_utf8RawData, buffer.Span); + + //Parse the private key + RSA alg = RSA.Create(); + + alg.ImportPkcs8PrivateKey(buffer.Span[..(int)count], out _); + + //Wipe the buffer + MemoryUtil.InitializeBlock(buffer.Span); + + return alg; + } + + internal PrivateKey(ISecretResult secret) + { + //Alloc and get utf8 + byte[] buffer = new byte[secret.Result.Length]; + + int count = Encoding.UTF8.GetBytes(secret.Result, buffer); + + //Verify length + if(count != buffer.Length) + { + throw new FormatException("UTF8 deocde failed"); + } + + //Store + _utf8RawData = buffer; + } + + protected override void Free() + { + MemoryUtil.InitializeBlock(_utf8RawData.AsSpan()); + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/SecretResult.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/SecretResult.cs new file mode 100644 index 0000000..f2cbd28 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/SecretResult.cs @@ -0,0 +1,60 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: SecretResult.cs +* +* SecretResult.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; + +using VNLib.Utils; +using VNLib.Utils.Extensions; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Extensions.Loading +{ + + /// + /// The result of a secret fetch operation + /// + public sealed class SecretResult : VnDisposeable, ISecretResult + { + private readonly char[] _secretChars; + + /// + public ReadOnlySpan Result => _secretChars; + + + internal SecretResult(ReadOnlySpan value) => _secretChars = value.ToArray(); + + /// + protected override void Free() + { + MemoryUtil.InitializeBlock(_secretChars.AsSpan()); + } + + internal static SecretResult ToSecret(string? result) + { + SecretResult res = new(result.AsSpan()); + MemoryUtil.UnsafeZeroMemory(result); + return res; + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/VaultSecrets.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/VaultSecrets.cs new file mode 100644 index 0000000..711ae50 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Secrets/VaultSecrets.cs @@ -0,0 +1,572 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: VaultSecrets.cs +* +* VaultSecrets.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; + +using VaultSharp; +using VaultSharp.V1.Commons; +using VaultSharp.V1.AuthMethods; +using VaultSharp.V1.AuthMethods.Token; +using VaultSharp.V1.AuthMethods.AppRole; +using VaultSharp.V1.SecretsEngines.PKI; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Hashing.IdentityUtility; + +namespace VNLib.Plugins.Extensions.Loading +{ + + /// + /// Adds loading extensions for secure/centralized configuration secrets + /// + public static class PluginSecretLoading + { + public const string VAULT_OBJECT_NAME = "hashicorp_vault"; + public const string SECRETS_CONFIG_KEY = "secrets"; + public const string VAULT_TOKEN_KEY = "token"; + public const string VAULT_ROLE_KEY = "role"; + public const string VAULT_SECRET_KEY = "secret"; + public const string VAULT_TOKNE_ENV_NAME = "VNLIB_PLUGINS_VAULT_TOKEN"; + + public const string VAULT_URL_KEY = "url"; + + public const string VAULT_URL_SCHEME = "vault://"; + + + /// + /// + /// Gets a secret from the "secrets" element. + /// + /// + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// + /// + /// + /// The name of the secret propery to get + /// The element from the configuration file with the given name, or null if the configuration or property does not exist + /// + /// + public static async Task GetSecretAsync(this PluginBase plugin, string secretName) + { + ISecretResult? res = await TryGetSecretAsync(plugin, secretName).ConfigureAwait(false); + return res ?? throw new KeyNotFoundException($"Missing required secret {secretName}"); + } + + /// + /// + /// Gets a secret from the "secrets" element. + /// + /// + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// + /// + /// + /// The name of the secret propery to get + /// The element from the configuration file with the given name, or null if the configuration or property does not exist + /// + /// + public static Task TryGetSecretAsync(this PluginBase plugin, string secretName) + { + plugin.ThrowIfUnloaded(); + + //Get the secret from the config file raw + string? rawSecret = TryGetSecretInternal(plugin, secretName); + + if (rawSecret == null) + { + return Task.FromResult(null); + } + + //Secret is a vault path, or return the raw value + if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new SecretResult(rawSecret.AsSpan())); + } + + return GetSecretFromVaultAsync(plugin, rawSecret); + } + + /// + /// Gets a secret at the given vault url (in the form of "vault://[mount-name]/[secret-path]?secret=[secret_name]") + /// + /// + /// The raw vault url to lookup + /// The string of the object at the specified vault path + /// + /// + /// + public static Task GetSecretFromVaultAsync(this PluginBase plugin, ReadOnlySpan vaultPath) + { + //print the path for debug + if (plugin.IsDebug()) + { + plugin.Log.Debug("Retrieving secret {s} from vault", vaultPath.ToString()); + } + + //Slice off path + ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); + ReadOnlySpan path = paq.SliceBeforeParam('?'); + ReadOnlySpan query = paq.SliceAfterParam('?'); + + if (paq.IsEmpty) + { + throw new UriFormatException("Vault secret location not valid/empty "); + } + //Get the secret + string secretTableKey = query.SliceAfterParam("secret=").SliceBeforeParam('&').ToString(); + string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); + + //get mount and path + int lastSep = path.IndexOf('/'); + string mount = path[..lastSep].ToString(); + string secret = path[(lastSep + 1)..].ToString(); + + async Task execute() + { + //Try load client + IVaultClient? client = plugin.GetVault(); + + _ = client ?? throw new KeyNotFoundException("Vault client not found"); + //run read async + Secret result = await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path:secret, mountPoint:mount); + //Read the secret + return SecretResult.ToSecret(result.Data.Data[secretTableKey].ToString()); + } + + return Task.Run(execute); + } + + /// + /// + /// Gets a Certicate from the "secrets" element. + /// + /// + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// + /// + /// + /// The name of the secret propery to get + /// The element from the configuration file with the given name, or null if the configuration or property does not exist + /// + /// + public static Task TryGetCertificateAsync(this PluginBase plugin, string secretName) + { + plugin.ThrowIfUnloaded(); + //Get the secret from the config file raw + string? rawSecret = TryGetSecretInternal(plugin, secretName); + if (rawSecret == null) + { + return Task.FromResult(null); + } + + //Secret is a vault path, or return the raw value + if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new (rawSecret)); + } + return GetCertFromVaultAsync(plugin, rawSecret); + } + + public static Task GetCertFromVaultAsync(this PluginBase plugin, ReadOnlySpan vaultPath, CertificateCredentialsRequestOptions? options = null) + { + //print the path for debug + if (plugin.IsDebug()) + { + plugin.Log.Debug("Retrieving certificate {s} from vault", vaultPath.ToString()); + } + + //Slice off path + ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); + ReadOnlySpan path = paq.SliceBeforeParam('?'); + ReadOnlySpan query = paq.SliceAfterParam('?'); + + if (paq.IsEmpty) + { + throw new UriFormatException("Vault secret location not valid/empty "); + } + + //Get the secret + string role = query.SliceAfterParam("role=").SliceBeforeParam('&').ToString(); + string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); + string commonName = query.SliceBeforeParam("cn=").SliceBeforeParam('&').ToString(); + + //get mount and path + int lastSep = path.IndexOf('/'); + string mount = path[..lastSep].ToString(); + string secret = path[(lastSep + 1)..].ToString(); + + async Task execute() + { + //Try load client + IVaultClient? client = plugin.GetVault(); + + _ = client ?? throw new KeyNotFoundException("Vault client not found"); + + options ??= new() + { + CertificateFormat = CertificateFormat.pem, + PrivateKeyFormat = PrivateKeyFormat.pkcs8, + CommonName = commonName, + }; + + //run read async + Secret result = await client.V1.Secrets.PKI.GetCredentialsAsync(pkiRoleName:secret, certificateCredentialRequestOptions:options, pkiBackendMountPoint:mount); + //Read the secret + byte[] pemCertData = Encoding.UTF8.GetBytes(result.Data.CertificateContent); + + return new (pemCertData); + } + + return Task.Run(execute); + } + + /// + /// Gets the ambient vault client for the current plugin + /// if the configuration is loaded, null otherwise + /// + /// + /// The ambient if loaded, null otherwise + /// + /// + public static IVaultClient? GetVault(this PluginBase plugin) => LoadingExtensions.GetOrCreateSingleton(plugin, TryGetVaultLoader); + + private static string? TryGetSecretInternal(PluginBase plugin, string secretName) + { + bool local = plugin.PluginConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement localEl); + bool host = plugin.HostConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement hostEl); + + //total config + IReadOnlyDictionary? conf; + + if (local && host) + { + //Load both config objects to dict + Dictionary localConf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + Dictionary hostConf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + + //merge the two configs + foreach(KeyValuePair lc in localConf) + { + //Overwrite any host config keys, plugin conf takes priority + hostConf[lc.Key] = lc.Value; + } + //set the merged config + conf = hostConf; + } + else if(local) + { + //Store only local config + conf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + } + else if(host) + { + //store only host config + conf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + } + else + { + conf = null; + } + + //Get the value or default json element + return conf != null && conf.TryGetValue(secretName, out JsonElement el) ? el.GetString() : null; + } + + private static IVaultClient? TryGetVaultLoader(PluginBase pbase) + { + //Get vault config + IConfigScope? conf = pbase.TryGetConfig(VAULT_OBJECT_NAME); + + if (conf == null) + { + return null; + } + + //try get servre address creds from config + string? serverAddress = conf[VAULT_URL_KEY].GetString() ?? throw new KeyNotFoundException($"Failed to load the key {VAULT_URL_KEY} from object {VAULT_OBJECT_NAME}"); + + IAuthMethodInfo authMethod; + + //Get authentication method from config + if (conf.TryGetValue(VAULT_TOKEN_KEY, out JsonElement tokenEl)) + { + //Init token + authMethod = new TokenAuthMethodInfo(tokenEl.GetString()); + } + else if (conf.TryGetValue(VAULT_ROLE_KEY, out JsonElement roleEl) && conf.TryGetValue(VAULT_SECRET_KEY, out JsonElement secretEl)) + { + authMethod = new AppRoleAuthMethodInfo(roleEl.GetString(), secretEl.GetString()); + } + //Try to get the token as an environment variable + else if(Environment.GetEnvironmentVariable(VAULT_TOKNE_ENV_NAME) != null) + { + string tokenValue = Environment.GetEnvironmentVariable(VAULT_TOKNE_ENV_NAME)!; + authMethod = new TokenAuthMethodInfo(tokenValue); + } + else + { + throw new KeyNotFoundException($"Failed to load the vault authentication method from {VAULT_OBJECT_NAME}"); + } + + //Settings + VaultClientSettings settings = new(serverAddress, authMethod); + + //create vault client + return new VaultClient(settings); + } + + /// + /// Gets the Secret value as a byte buffer + /// + /// + /// The base64 decoded secret as a byte[] + /// + /// + public static byte[] GetFromBase64(this ISecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + //Temp buffer + using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAlloc(secret.Result.Length); + + //Get base64 + if(!Convert.TryFromBase64Chars(secret.Result, buffer, out int count)) + { + throw new InternalBufferTooSmallException("internal buffer too small"); + } + + //Copy to array + byte[] value = buffer.Span[..count].ToArray(); + + //Clear block before returning + MemoryUtil.InitializeBlock(buffer); + + return value; + } + + /// + /// Recovers a certificate from a PEM encoded secret + /// + /// + /// The parsed from the PEM encoded data + /// + public static X509Certificate2 GetCertificate(this ISecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + return X509Certificate2.CreateFromPem(secret.Result); + } + + /// + /// Gets the secret value as a secret result + /// + /// + /// The document parsed from the secret value + public static JsonDocument GetJsonDocument(this ISecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + //Alloc buffer, utf8 so 1 byte per char + using IMemoryHandle buffer = MemoryUtil.SafeAlloc(secret.Result.Length); + + //Get utf8 bytes + int count = Encoding.UTF8.GetBytes(secret.Result, buffer.Span); + + //Reader and parse + Utf8JsonReader reader = new(buffer.Span[..count]); + + return JsonDocument.ParseValue(ref reader); + } + + /// + /// Gets a SPKI encoded public key from a secret + /// + /// + /// The parsed from the SPKI public key + /// + public static PublicKey GetPublicKey(this ISecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + //Alloc buffer, base64 is larger than binary value so char len is large enough + using IMemoryHandle buffer = MemoryUtil.SafeAlloc(secret.Result.Length); + + //Get base64 bytes + ERRNO count = VnEncoding.TryFromBase64Chars(secret.Result, buffer.Span); + + //Parse the SPKI from base64 + return PublicKey.CreateFromSubjectPublicKeyInfo(buffer.Span[..(int)count], out _); + } + + /// + /// Gets the value of the as a + /// container + /// + /// + /// The from the secret value + /// + /// + public static PrivateKey GetPrivateKey(this ISecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + return new PrivateKey(secret); + } + + /// + /// Gets a from a secret value + /// + /// + /// The from the result + /// + /// + /// + public static ReadOnlyJsonWebKey GetJsonWebKey(this ISecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + //Alloc buffer, utf8 so 1 byte per char + using IMemoryHandle buffer = MemoryUtil.SafeAlloc(secret.Result.Length); + + //Get utf8 bytes + int count = Encoding.UTF8.GetBytes(secret.Result, buffer.Span); + + return new ReadOnlyJsonWebKey(buffer.Span[..count]); + } + +#nullable disable + + /// + /// Converts the secret recovery task to return the base64 decoded secret as a byte[] + /// + /// + /// A task whos result the base64 decoded secret as a byte[] + /// + /// + public static async Task ToBase64Bytes(this Task secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + using ISecretResult sec = await secret.ConfigureAwait(false); + + return sec?.GetFromBase64(); + } + + /// + /// Gets a task that resolves a + /// from a task + /// + /// + /// The from the secret, or null if the secret was not found + /// + public static async Task ToJsonWebKey(this Task secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + using ISecretResult sec = await secret.ConfigureAwait(false); + + return sec?.GetJsonWebKey(); + } + + /// + /// Gets a task that resolves a + /// from a task + /// + /// + /// + /// A value that inidcates that a value is required from the result, + /// or a is raised + /// + /// The from the secret, or throws if the key was not found + /// + /// + public static async Task ToJsonWebKey(this Task secret, bool required) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + using ISecretResult sec = await secret.ConfigureAwait(false); + + //If required is true and result is null, raise an exception + return required && sec == null ? throw new KeyNotFoundException("A required secret was missing") : (sec?.GetJsonWebKey()!); + } + + /// + /// Converts a async operation to a lazy result that can be awaited, that transforms the result + /// to your desired type. If the result is null, the default value of is returned + /// + /// + /// + /// Your function to transform the secret to its output form + /// A + /// + public static IAsyncLazy ToLazy(this Task result, Func transformer) + { + _ = result ?? throw new ArgumentNullException(nameof(result)); + _ = transformer ?? throw new ArgumentNullException(nameof(transformer)); + + //standard secret transformer + static async Task Run(Task tr, Func transformer) + { + using ISecretResult res = await tr.ConfigureAwait(false); + return res == null ? default : transformer(res); + } + + return Run(result, transformer).AsLazy(); + } + + /// + /// Converts a async operation to a lazy result that can be awaited, that transforms the result + /// to your desired type. If the result is null, the default value of is returned + /// + /// + /// + /// Your function to transform the secret to its output form + /// A + /// + public static IAsyncLazy ToLazy(this Task result, Func> transformer) + { + _ = result ?? throw new ArgumentNullException(nameof(result)); + _ = transformer ?? throw new ArgumentNullException(nameof(transformer)); + + //Transform with task transformer + static async Task Run(Task tr, Func> transformer) + { + using ISecretResult res = await tr.ConfigureAwait(false); + return res == null ? default : await transformer(res).ConfigureAwait(false); + } + + return Run(result, transformer).AsLazy(); + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs b/lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs deleted file mode 100644 index 9e3c222..0000000 --- a/lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs +++ /dev/null @@ -1,498 +0,0 @@ -/* -* Copyright (c) 2023 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Extensions.Loading -* File: VaultSecrets.cs -* -* VaultSecrets.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger -* VNLib collection of libraries and utilities. -* -* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify -* it under the terms of the GNU Affero General Public License as -* published by the Free Software Foundation, either version 3 of the -* License, or (at your option) any later version. -* -* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU Affero General Public License for more details. -* -* You should have received a copy of the GNU Affero General Public License -* along with this program. If not, see https://www.gnu.org/licenses/. -*/ - -using System; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Security.Cryptography.X509Certificates; - -using VaultSharp; -using VaultSharp.V1.Commons; -using VaultSharp.V1.AuthMethods; -using VaultSharp.V1.AuthMethods.Token; -using VaultSharp.V1.AuthMethods.AppRole; -using VaultSharp.V1.SecretsEngines.PKI; - -using VNLib.Utils; -using VNLib.Utils.Memory; -using VNLib.Utils.Logging; -using VNLib.Utils.Extensions; -using VNLib.Hashing.IdentityUtility; - -namespace VNLib.Plugins.Extensions.Loading -{ - - /// - /// Adds loading extensions for secure/centralized configuration secrets - /// - public static class PluginSecretLoading - { - public const string VAULT_OBJECT_NAME = "hashicorp_vault"; - public const string SECRETS_CONFIG_KEY = "secrets"; - public const string VAULT_TOKEN_KEY = "token"; - public const string VAULT_ROLE_KEY = "role"; - public const string VAULT_SECRET_KEY = "secret"; - public const string VAULT_TOKNE_ENV_NAME = "VNLIB_PLUGINS_VAULT_TOKEN"; - - public const string VAULT_URL_KEY = "url"; - - public const string VAULT_URL_SCHEME = "vault://"; - - - /// - /// - /// Gets a secret from the "secrets" element. - /// - /// - /// Secrets elements are merged from the host config and plugin local config 'secrets' element. - /// before searching. The plugin config takes precedence over the host config. - /// - /// - /// - /// The name of the secret propery to get - /// The element from the configuration file with the given name, or null if the configuration or property does not exist - /// - /// - public static Task TryGetSecretAsync(this PluginBase plugin, string secretName) - { - plugin.ThrowIfUnloaded(); - //Get the secret from the config file raw - string? rawSecret = TryGetSecretInternal(plugin, secretName); - if (rawSecret == null) - { - return Task.FromResult(null); - } - - //Secret is a vault path, or return the raw value - if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new(rawSecret.AsSpan())); - } - return GetSecretFromVaultAsync(plugin, rawSecret); - } - - /// - /// Gets a secret at the given vault url (in the form of "vault://[mount-name]/[secret-path]?secret=[secret_name]") - /// - /// - /// The raw vault url to lookup - /// The string of the object at the specified vault path - /// - /// - /// - public static Task GetSecretFromVaultAsync(this PluginBase plugin, ReadOnlySpan vaultPath) - { - //print the path for debug - if (plugin.IsDebug()) - { - plugin.Log.Debug("Retrieving secret {s} from vault", vaultPath.ToString()); - } - - //Slice off path - ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); - ReadOnlySpan path = paq.SliceBeforeParam('?'); - ReadOnlySpan query = paq.SliceAfterParam('?'); - - if (paq.IsEmpty) - { - throw new UriFormatException("Vault secret location not valid/empty "); - } - //Get the secret - string secretTableKey = query.SliceAfterParam("secret=").SliceBeforeParam('&').ToString(); - string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); - - //get mount and path - int lastSep = path.IndexOf('/'); - string mount = path[..lastSep].ToString(); - string secret = path[(lastSep + 1)..].ToString(); - - async Task execute() - { - //Try load client - IVaultClient? client = plugin.GetVault(); - - _ = client ?? throw new KeyNotFoundException("Vault client not found"); - //run read async - Secret result = await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path:secret, mountPoint:mount); - //Read the secret - return SecretResult.ToSecret(result.Data.Data[secretTableKey].ToString()); - } - - return Task.Run(execute); - } - - /// - /// - /// Gets a Certicate from the "secrets" element. - /// - /// - /// Secrets elements are merged from the host config and plugin local config 'secrets' element. - /// before searching. The plugin config takes precedence over the host config. - /// - /// - /// - /// The name of the secret propery to get - /// The element from the configuration file with the given name, or null if the configuration or property does not exist - /// - /// - public static Task TryGetCertificateAsync(this PluginBase plugin, string secretName) - { - plugin.ThrowIfUnloaded(); - //Get the secret from the config file raw - string? rawSecret = TryGetSecretInternal(plugin, secretName); - if (rawSecret == null) - { - return Task.FromResult(null); - } - - //Secret is a vault path, or return the raw value - if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new (rawSecret)); - } - return GetCertFromVaultAsync(plugin, rawSecret); - } - - public static Task GetCertFromVaultAsync(this PluginBase plugin, ReadOnlySpan vaultPath, CertificateCredentialsRequestOptions? options = null) - { - //print the path for debug - if (plugin.IsDebug()) - { - plugin.Log.Debug("Retrieving certificate {s} from vault", vaultPath.ToString()); - } - - //Slice off path - ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); - ReadOnlySpan path = paq.SliceBeforeParam('?'); - ReadOnlySpan query = paq.SliceAfterParam('?'); - - if (paq.IsEmpty) - { - throw new UriFormatException("Vault secret location not valid/empty "); - } - - //Get the secret - string role = query.SliceAfterParam("role=").SliceBeforeParam('&').ToString(); - string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); - string commonName = query.SliceBeforeParam("cn=").SliceBeforeParam('&').ToString(); - - //get mount and path - int lastSep = path.IndexOf('/'); - string mount = path[..lastSep].ToString(); - string secret = path[(lastSep + 1)..].ToString(); - - async Task execute() - { - //Try load client - IVaultClient? client = plugin.GetVault(); - - _ = client ?? throw new KeyNotFoundException("Vault client not found"); - - options ??= new() - { - CertificateFormat = CertificateFormat.pem, - PrivateKeyFormat = PrivateKeyFormat.pkcs8, - CommonName = commonName, - }; - - //run read async - Secret result = await client.V1.Secrets.PKI.GetCredentialsAsync(pkiRoleName:secret, certificateCredentialRequestOptions:options, pkiBackendMountPoint:mount); - //Read the secret - byte[] pemCertData = Encoding.UTF8.GetBytes(result.Data.CertificateContent); - - return new (pemCertData); - } - - return Task.Run(execute); - } - - /// - /// Gets the ambient vault client for the current plugin - /// if the configuration is loaded, null otherwise - /// - /// - /// The ambient if loaded, null otherwise - /// - /// - public static IVaultClient? GetVault(this PluginBase plugin) => LoadingExtensions.GetOrCreateSingleton(plugin, TryGetVaultLoader); - - private static string? TryGetSecretInternal(PluginBase plugin, string secretName) - { - bool local = plugin.PluginConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement localEl); - bool host = plugin.HostConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement hostEl); - - //total config - IReadOnlyDictionary? conf; - - if (local && host) - { - //Load both config objects to dict - Dictionary localConf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); - Dictionary hostConf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); - - //merge the two configs - foreach(KeyValuePair lc in localConf) - { - //Overwrite any host config keys, plugin conf takes priority - hostConf[lc.Key] = lc.Value; - } - //set the merged config - conf = hostConf; - } - else if(local) - { - //Store only local config - conf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); - } - else if(host) - { - //store only host config - conf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); - } - else - { - conf = null; - } - - //Get the value or default json element - return conf != null && conf.TryGetValue(secretName, out JsonElement el) ? el.GetString() : null; - } - - private static IVaultClient? TryGetVaultLoader(PluginBase pbase) - { - //Get vault config - IConfigScope? conf = pbase.TryGetConfig(VAULT_OBJECT_NAME); - - if (conf == null) - { - return null; - } - - //try get servre address creds from config - string? serverAddress = conf[VAULT_URL_KEY].GetString() ?? throw new KeyNotFoundException($"Failed to load the key {VAULT_URL_KEY} from object {VAULT_OBJECT_NAME}"); - - IAuthMethodInfo authMethod; - - //Get authentication method from config - if (conf.TryGetValue(VAULT_TOKEN_KEY, out JsonElement tokenEl)) - { - //Init token - authMethod = new TokenAuthMethodInfo(tokenEl.GetString()); - } - else if (conf.TryGetValue(VAULT_ROLE_KEY, out JsonElement roleEl) && conf.TryGetValue(VAULT_SECRET_KEY, out JsonElement secretEl)) - { - authMethod = new AppRoleAuthMethodInfo(roleEl.GetString(), secretEl.GetString()); - } - //Try to get the token as an environment variable - else if(Environment.GetEnvironmentVariable(VAULT_TOKNE_ENV_NAME) != null) - { - string tokenValue = Environment.GetEnvironmentVariable(VAULT_TOKNE_ENV_NAME)!; - authMethod = new TokenAuthMethodInfo(tokenValue); - } - else - { - throw new KeyNotFoundException($"Failed to load the vault authentication method from {VAULT_OBJECT_NAME}"); - } - - //Settings - VaultClientSettings settings = new(serverAddress, authMethod); - - //create vault client - return new VaultClient(settings); - } - - /// - /// Gets the Secret value as a byte buffer - /// - /// - /// The base64 decoded secret as a byte[] - /// - /// - public static byte[] GetFromBase64(this SecretResult secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - //Temp buffer - using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAlloc(secret.Result.Length); - - //Get base64 - if(!Convert.TryFromBase64Chars(secret.Result, buffer, out int count)) - { - throw new InternalBufferTooSmallException("internal buffer too small"); - } - - //Copy to array - byte[] value = buffer.Span[..count].ToArray(); - - //Clear block before returning - MemoryUtil.InitializeBlock(buffer); - - return value; - } - - /// - /// Converts the secret recovery task to - /// - /// - /// A task whos result the base64 decoded secret as a byte[] - /// - /// - public static async Task ToBase64Bytes(this Task secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - using SecretResult? sec = await secret.ConfigureAwait(false); - - return sec?.GetFromBase64(); - } - - /// - /// Recovers a certificate from a PEM encoded secret - /// - /// - /// The parsed from the PEM encoded data - /// - public static X509Certificate2 GetCertificate(this SecretResult secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - return X509Certificate2.CreateFromPem(secret.Result); - } - - /// - /// Gets the secret value as a secret result - /// - /// - /// The document parsed from the secret value - public static JsonDocument GetJsonDocument(this SecretResult secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - //Alloc buffer, utf8 so 1 byte per char - using IMemoryHandle buffer = MemoryUtil.SafeAlloc(secret.Result.Length); - - //Get utf8 bytes - int count = Encoding.UTF8.GetBytes(secret.Result, buffer.Span); - - //Reader and parse - Utf8JsonReader reader = new(buffer.Span[..count]); - - return JsonDocument.ParseValue(ref reader); - } - - /// - /// Gets a SPKI encoded public key from a secret - /// - /// - /// The parsed from the SPKI public key - /// - public static PublicKey GetPublicKey(this SecretResult secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - //Alloc buffer, base64 is larger than binary value so char len is large enough - using IMemoryHandle buffer = MemoryUtil.SafeAlloc(secret.Result.Length); - - //Get base64 bytes - ERRNO count = VnEncoding.TryFromBase64Chars(secret.Result, buffer.Span); - - //Parse the SPKI from base64 - return PublicKey.CreateFromSubjectPublicKeyInfo(buffer.Span[..(int)count], out _); - } - - /// - /// Gets the value of the as a - /// container - /// - /// - /// The from the secret value - /// - /// - public static PrivateKey GetPrivateKey(this SecretResult secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - return new PrivateKey(secret); - } - - /// - /// Gets a from a secret value - /// - /// - /// The from the result - /// - /// - /// - public static ReadOnlyJsonWebKey GetJsonWebKey(this SecretResult secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - //Alloc buffer, utf8 so 1 byte per char - using IMemoryHandle buffer = MemoryUtil.SafeAlloc(secret.Result.Length); - - //Get utf8 bytes - int count = Encoding.UTF8.GetBytes(secret.Result, buffer.Span); - - return new ReadOnlyJsonWebKey(buffer.Span[..count]); - } - - /// - /// Gets a task that resolves a - /// from a task - /// - /// - /// The from the secret, or null if the secret was not found - /// - public static async Task ToJsonWebKey(this Task secret) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - using SecretResult? sec = await secret.ConfigureAwait(false); - - return sec?.GetJsonWebKey(); - } - - /// - /// Gets a task that resolves a - /// from a task - /// - /// - /// - /// A value that inidcates that a value is required from the result, - /// or a is raised - /// - /// The from the secret, or null if the secret was not found - /// - public static async Task ToJsonWebKey(this Task secret, bool required) - { - _ = secret ?? throw new ArgumentNullException(nameof(secret)); - - using SecretResult? sec = await secret.ConfigureAwait(false); - - //If required is true and result is null, raise an exception - return required && sec == null ? throw new KeyNotFoundException("A required secret was missing") : (sec?.GetJsonWebKey()!); - } - } -} -- cgit