diff --git a/Directory.Build.props b/Directory.Build.props index 40f433882..c70a855e4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -95,7 +95,7 @@ 4.6.0 4.36.0 4.57.0-preview - 9.0.0 + 9.1.0 8.0.5 diff --git a/NuGet.Config b/NuGet.Config index 7604d0051..b6260ec73 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -3,5 +3,6 @@ + diff --git a/docs/blog-posts/symmetric-key-support.md b/docs/blog-posts/symmetric-key-support.md new file mode 100644 index 000000000..2f581fa4e --- /dev/null +++ b/docs/blog-posts/symmetric-key-support.md @@ -0,0 +1,220 @@ +# Adding Support for Symmetric Keys in Microsoft.Identity.Web + +## Overview +This proposal outlines the addition of symmetric key support for signing credentials in Microsoft.Identity.Web, allowing keys to be loaded from Key Vault or Base64 encoded strings while maintaining clean abstractions. + +## Requirements +1. Support symmetric keys from: + - Azure Key Vault secrets + - Base64 encoded strings +2. Avoid circular dependencies with Microsoft.IdentityModel +3. Follow existing patterns in the codebase +4. Maintain backward compatibility + +## Developer Experience +The implementation provides a straightforward and type-safe approach to working with symmetric keys while maintaining clean separation of concerns: + +### Key Management +When working with symmetric keys, developers can utilize two primary sources: + +1. **Azure Key Vault Integration** + ```csharp + var credentials = new CredentialDescription + { + SourceType = CredentialSource.SymmetricKeyFromKeyVault, + KeyVaultUrl = "https://your-vault.vault.azure.net", + KeyVaultSecretName = "your-secret-name" + }; + ``` + +2. **Direct Base64 Encoded Keys** + ```csharp + var credentials = new CredentialDescription + { + SourceType = CredentialSource.SymmetricKeyBase64Encoded, + Base64EncodedValue = "your-base64-encoded-key" + }; + ``` + +### Implementation Details +- The DefaultCredentialLoader automatically selects the appropriate loader based on the SourceType +- Key material is loaded and converted to a SymmetricSecurityKey +- The security key is stored in the CachedValue property of CredentialDescription +- This design maintains independence from Microsoft.IdentityModel types in the abstractions layer +- The implementation follows the same pattern as certificate handling for consistency + +## Design + +### 1. New CredentialSource Values(Abstractions Layer) +```csharp +public enum CredentialSource +{ + // Existing values + Certificate = 0, + KeyVault = 1, + Base64Encoded = 2, + Path = 3, + StoreWithThumbprint = 4, + StoreWithDistinguishedName = 5, + + // New values + SymmetricKeyFromKeyVault = 6, + SymmetricKeyBase64Encoded = 7 +} +``` + +### 2. SymmetricKeyDescription Class(IdWeb Layer) +Following the same pattern as CertificateDescription: + +```csharp +public class SymmetricKeyDescription : CredentialDescription +{ + public static SymmetricKeyDescription FromKeyVault(string keyVaultUrl, string secretName) + { + return new SymmetricKeyDescription + { + SourceType = CredentialSource.SymmetricKeyFromKeyVault, + KeyVaultUrl = keyVaultUrl, + KeyVaultSecretName = secretName + }; + } + + public static SymmetricKeyDescription FromBase64Encoded(string base64EncodedValue) + { + return new SymmetricKeyDescription + { + SourceType = CredentialSource.SymmetricKeyBase64Encoded, + Base64EncodedValue = base64EncodedValue + }; + } +} +``` + +### 3. New Loader Classes(IdWeb Layer) +Internal implementation in Microsoft.Identity.Web: + +```csharp +internal class KeyVaultSymmetricKeyLoader : ICredentialSourceLoader +{ + private readonly SecretClient _secretClient; + + public KeyVaultSymmetricKeyLoader(SecretClient secretClient) + { + _secretClient = secretClient ?? throw new ArgumentNullException(nameof(secretClient)); + } + + public async Task LoadIfNeededAsync(CredentialDescription description, CredentialSourceLoaderParameters? parameters) + { + _ = Throws.IfNull(description); + + if (description.CachedValue != null) + return; + + if (string.IsNullOrEmpty(description.KeyVaultUrl)) + throw new ArgumentException("KeyVaultUrl is required for KeyVault source"); + + if (string.IsNullOrEmpty(description.KeyVaultSecretName)) + throw new ArgumentException("KeyVaultSecretName is required for KeyVault source"); + + // Load secret from Key Vault + var secret = await _secretClient.GetSecretAsync(description.KeyVaultSecretName).ConfigureAwait(false); + if (secret?.Value == null) + throw new InvalidOperationException($"Secret {description.KeyVaultSecretName} not found in Key Vault"); + + try + { + // Convert secret value to bytes and create SymmetricSecurityKey + var keyBytes = Convert.FromBase64String(secret.Value.Value); + description.CachedValue = new SymmetricSecurityKey(keyBytes); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to create symmetric key from Key Vault secret: {ex.Message}", ex); + } + } +} + +internal class Base64EncodedSymmetricKeyLoader : ICredentialSourceLoader +{ + public async Task LoadIfNeededAsync(CredentialDescription description, CredentialSourceLoaderParameters? parameters) + { + _ = Throws.IfNull(description); + + if (description.CachedValue != null) + return; + + if (string.IsNullOrEmpty(description.Base64EncodedValue)) + throw new ArgumentException("Base64EncodedValue is required for Base64Encoded source"); + + try + { + // Convert Base64 string to bytes and create SymmetricSecurityKey + var keyBytes = Convert.FromBase64String(description.Base64EncodedValue); + description.CachedValue = new SymmetricSecurityKey(keyBytes); + } + catch (Exception ex) + { + throw new FormatException("Invalid Base64 string for symmetric key", ex); + } + + await Task.CompletedTask.ConfigureAwait(false); + } +} +``` + +### 4. DefaultCredentialsLoader Changes(IdWeb Layer) +Update the loader to handle both certificate and symmetric key scenarios: + +```csharp +public partial class DefaultCredentialsLoader : ICredentialsLoader, ISigningCredentialsLoader +{ + public DefaultCredentialsLoader(ILogger? logger) + { + _logger = logger ?? new NullLogger(); + + CredentialSourceLoaders = new Dictionary + { + // Existing certificate loaders + { CredentialSource.KeyVault, new KeyVaultCertificateLoader() }, + { CredentialSource.Path, new FromPathCertificateLoader() }, + { CredentialSource.StoreWithThumbprint, new StoreWithThumbprintCertificateLoader() }, + { CredentialSource.StoreWithDistinguishedName, new StoreWithDistinguishedNameCertificateLoader() }, + { CredentialSource.Base64Encoded, new Base64EncodedCertificateLoader() }, + + // New symmetric key loaders + { CredentialSource.SymmetricKeyFromKeyVault, new KeyVaultSymmetricKeyLoader(_secretClient) }, + { CredentialSource.SymmetricKeyBase64Encoded, new Base64EncodedSymmetricKeyLoader() } + }; + } + + public async Task LoadSigningCredentialsAsync( + CredentialDescription credentialDescription, + CredentialSourceLoaderParameters? parameters = null) + { + _ = Throws.IfNull(credentialDescription); + + try + { + await LoadCredentialsIfNeededAsync(credentialDescription, parameters); + + if (credentialDescription.Certificate != null) + { + return new X509SigningCredentials( + credentialDescription.Certificate, + credentialDescription.Algorithm); + } + else if (credentialDescription.CachedValue is SymmetricSecurityKey key) + { + return new SigningCredentials(key, credentialDescription.Algorithm); + } + + return null; + } + catch (Exception ex) + { + Logger.CredentialLoadingFailure(_logger, credentialDescription, ex); + throw; + } + } +} +``` diff --git a/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs b/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs index 7b6cade30..63391b656 100644 --- a/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs +++ b/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs @@ -9,13 +9,14 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Identity.Abstractions; +using Microsoft.IdentityModel.Tokens; namespace Microsoft.Identity.Web { /// /// Default credentials loader. /// - public partial class DefaultCredentialsLoader : ICredentialsLoader + public partial class DefaultCredentialsLoader : ICredentialsLoader, ISigningCredentialsLoader { private readonly ILogger _logger; private readonly ConcurrentDictionary _loadingSemaphores = new ConcurrentDictionary(); @@ -48,13 +49,13 @@ public DefaultCredentialsLoader() : this(null) } /// - /// Dictionary of credential loaders per credential source. The application can add more to + /// Dictionary of credential loaders per credential source. The application can add more to /// process additional credential sources(like dSMS). /// public IDictionary CredentialSourceLoaders { get; } /// - /// Load the credentials from the description, if needed. + /// Load the credentials from the description, if needed. /// Important: Ignores SKIP flag, propagates exceptions. public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialDescription, CredentialSourceLoaderParameters? parameters = null) { @@ -99,9 +100,9 @@ public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialD } /// - /// Loads first valid credential which is not marked as Skipped. + /// Loads first valid credential which is not marked as Skipped. public async Task LoadFirstValidCredentialsAsync( - IEnumerable credentialDescriptions, + IEnumerable credentialDescriptions, CredentialSourceLoaderParameters? parameters = null) { foreach (var credentialDescription in credentialDescriptions) @@ -116,6 +117,31 @@ public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialD return null; } + /// + public async Task LoadSigningCredentialsAsync( + CredentialDescription credentialDescription, + CredentialSourceLoaderParameters? parameters = null) + { + _ = Throws.IfNull(credentialDescription); + + try + { + await LoadCredentialsIfNeededAsync(credentialDescription, parameters); + + if (credentialDescription.Certificate != null) + { + return new X509SigningCredentials(credentialDescription.Certificate, credentialDescription.Algorithm); + } + + return null; + } + catch (Exception ex) + { + Logger.CredentialLoadingFailure(_logger, credentialDescription, ex); + throw; + } + } + /// public void ResetCredentials(IEnumerable credentialDescriptions) { diff --git a/src/Microsoft.Identity.Web.Certificate/ISigningCredentialsLoader.cs b/src/Microsoft.Identity.Web.Certificate/ISigningCredentialsLoader.cs new file mode 100644 index 000000000..2a703c618 --- /dev/null +++ b/src/Microsoft.Identity.Web.Certificate/ISigningCredentialsLoader.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.Identity.Abstractions; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.Identity.Web +{ + /// + /// Interface for loading signing credentials. + /// + public interface ISigningCredentialsLoader + { + /// + /// Loads SigningCredentials from the credential description. + /// + /// Credential description. + /// Optional parameters for loading credentials. + /// SigningCredentials if successful, null otherwise. + Task LoadSigningCredentialsAsync( + CredentialDescription credentialDescription, + CredentialSourceLoaderParameters? parameters = null); + } +} diff --git a/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt index e69de29bb..55bdbaf7d 100644 --- a/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +Microsoft.Identity.Web.DefaultCredentialsLoader.LoadSigningCredentialsAsync(Microsoft.Identity.Abstractions.CredentialDescription! credentialDescription, Microsoft.Identity.Abstractions.CredentialSourceLoaderParameters? parameters = null) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.ISigningCredentialsLoader +Microsoft.Identity.Web.ISigningCredentialsLoader.LoadSigningCredentialsAsync(Microsoft.Identity.Abstractions.CredentialDescription! credentialDescription, Microsoft.Identity.Abstractions.CredentialSourceLoaderParameters? parameters = null) -> System.Threading.Tasks.Task! \ No newline at end of file diff --git a/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCredentialsLoaderSigningCredentialsTests.cs b/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCredentialsLoaderSigningCredentialsTests.cs new file mode 100644 index 000000000..bfb20fc70 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCredentialsLoaderSigningCredentialsTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Test.Common; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace Microsoft.Identity.Web.Test.Certificates +{ + public class DefaultCredentialsLoaderSigningCredentialsTests + { + private readonly CustomMockLogger _logger; + private readonly DefaultCredentialsLoader _loader; + + public DefaultCredentialsLoaderSigningCredentialsTests() + { + _logger = new CustomMockLogger(); + _loader = new DefaultCredentialsLoader(_logger); + } + + [Fact] + public async Task LoadSigningCredentialsAsync_NullDescription_ThrowsArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync( + () => _loader.LoadSigningCredentialsAsync(null!, null)); + } + + [Theory] + [MemberData(nameof(LoadSigningCredentialsTheoryData))] + public async Task LoadSigningCredentialsAsync_Tests(SigningCredentialsTheoryData data) + { + // Act + if (data.ExpectedException != null) + { + var exception = await Assert.ThrowsAsync( + data.ExpectedException.GetType(), + () => _loader.LoadSigningCredentialsAsync(data.CredentialDescription, null)); + + return; + } + + var result = await _loader.LoadSigningCredentialsAsync(data.CredentialDescription, null); + + // Assert + if (data.ExpectNullResult) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(data.CredentialDescription.Algorithm, result.Algorithm); + Assert.NotNull(((X509SigningCredentials)result).Certificate); + } + } + + public static TheoryData LoadSigningCredentialsTheoryData() + { + return new TheoryData + { + // Test with Base64 encoded certificate with private key + new SigningCredentialsTheoryData + { + CredentialDescription = CertificateDescription.FromBase64Encoded( + TestConstants.CertificateX5cWithPrivateKey, + TestConstants.CertificateX5cWithPrivateKeyPassword), + ExpectNullResult = false, + Algorithm = SecurityAlgorithms.RsaSha512 + }, + + // Test with certificate from file + new SigningCredentialsTheoryData + { + CredentialDescription = CertificateDescription.FromPath( + "Certificates/SelfSignedTestCert.pfx", + TestConstants.CertificateX5cWithPrivateKeyPassword), + ExpectNullResult = false, + Algorithm = SecurityAlgorithms.RsaSha384 + }, + + // Test with invalid certificate description + new SigningCredentialsTheoryData + { + CredentialDescription = new CertificateDescription(), + ExpectNullResult = true, + Algorithm = SecurityAlgorithms.RsaSha256 + }, + + // Test with invalid Base64 value (should throw) + new SigningCredentialsTheoryData + { + CredentialDescription = CertificateDescription.FromBase64Encoded("invalid"), + ExpectNullResult = false, + ExpectedException = new FormatException(), + Algorithm = SecurityAlgorithms.RsaSha256 + }, + + // Test with invalid file path (should throw) + new SigningCredentialsTheoryData + { + CredentialDescription = CertificateDescription.FromPath( + "nonexistent.pfx", + TestConstants.CertificateX5cWithPrivateKeyPassword), + ExpectNullResult = false, + ExpectedException = new System.Security.Cryptography.CryptographicException(), + Algorithm = SecurityAlgorithms.RsaSha256 + } + }; + } + } + + public class SigningCredentialsTheoryData + { + public CertificateDescription CredentialDescription { get; set; } = new CertificateDescription(); + public bool ExpectNullResult { get; set; } + public Exception? ExpectedException { get; set; } + public string Algorithm + { + get => CredentialDescription.Algorithm!; + set => CredentialDescription.Algorithm = value; + } + } +}