diff --git a/eng/service.proj b/eng/service.proj index 76bfd9f815a8d..2fe31094a6853 100644 --- a/eng/service.proj +++ b/eng/service.proj @@ -16,11 +16,11 @@ - + - + @@ -71,4 +71,4 @@ SkipNonexistentProjects="false" SkipNonexistentTargets="true" /> - \ No newline at end of file + diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md b/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md index 2918d294fb489..e1015d230a8c9 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features Added +- Added `JsonWebKeyConverter` to support serializing and deserializing a `JsonWebKey` to a RFC 7517 JWK. ([#16155](https://github.com/Azure/azure-sdk-for-net/issues/16155)) - Added `KeyClient.GetCryptographyClient` to get a `CryptographyClient` that uses the same options, policies, and pipeline as the `KeyClient` that created it. ([#23786](https://github.com/Azure/azure-sdk-for-net/issues/23786)) - Added `KeyVaultKeyIdentifier.TryCreate` to parse key URIs without throwing an exception when invalid. ([#23146](https://github.com/Azure/azure-sdk-for-net/issues/23146)) diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/samples/Sample7_SerializeJsonWebKey.md b/sdk/keyvault/Azure.Security.KeyVault.Keys/samples/Sample7_SerializeJsonWebKey.md new file mode 100644 index 0000000000000..cbaaf8ca22259 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/samples/Sample7_SerializeJsonWebKey.md @@ -0,0 +1,95 @@ +# Serializing and encrypting with a JWK + +This sample demonstrates how to serialize a [JSON web key (JWK)][JWK] and use it in a `CryptographyClient` to perform +cryptographic operations requiring only the public key. We subsequently verify the operation by decrypting the +ciphertext in Key Vault or Managed HSM using the same key. + +To get started, you'll need a URI to an Azure Key Vault or Managed HSM. See the [README][] for links and instructions. + +## Creating a KeyClient + +To create a new `KeyClient` to create, get, update, or delete keys, you need the endpoint to an Azure Key Vault and credentials. +You can use the [DefaultAzureCredential][] to try a number of common authentication methods optimized for both running as a service and development. + +In the sample below, you can set `keyVaultUrl` based on an environment variable, configuration setting, or any way that works for your application. + +```C# Snippet:KeysSample7KeyClient +var keyClient = new KeyClient(new Uri(keyVaultUrl), new DefaultAzureCredential()); +``` + +## Creating a key + +First, create an RSA key which will be used to wrap and unwrap another key. + +```C# Snippet:KeysSample7CreateKey +string rsaKeyName = $"CloudRsaKey-{Guid.NewGuid()}"; +var rsaKey = new CreateRsaKeyOptions(rsaKeyName, hardwareProtected: false) +{ + KeySize = 2048, +}; + +KeyVaultKey cloudRsaKey = keyClient.CreateRsaKey(rsaKey); +Debug.WriteLine($"Key is returned with name {cloudRsaKey.Name} and type {cloudRsaKey.KeyType}"); +``` + +## Serialize the JWK + +The `KeyVaultKey.Key` property is the JSON web key (JWK) which can be serialized using `System.Text.Json`. You might +serialize the JWK to save it for future sessions or use it with other libraries. + +```C# Snippet:KeysSample7Serialize +using FileStream file = File.Create(path); +using (Utf8JsonWriter writer = new Utf8JsonWriter(file)) +{ + JsonSerializer.Serialize(writer, cloudRsaKey.Key); +} + +Debug.WriteLine($"Saved JWK to {path}"); +``` + +## Encrypting with the JWK + +Assuming you had saved the serialized JWK for future sessions, you can decrypt it before you need to use it: + +```C# Snippet:KeysSamples7Deserialize +byte[] buffer = File.ReadAllBytes(path); +JsonWebKey jwk = JsonSerializer.Deserialize(buffer); + +Debug.WriteLine($"Read JWK from {path} with ID {jwk.Id}"); +``` + +You can then create a new `CryptographyClient` from the JWK to perform cryptographic operations using what public +key information is contained within the JWK: + +```C# Snippet:KeysSample7Encrypt +var encryptClient = new CryptographyClient(jwk); + +byte[] plaintext = Encoding.UTF8.GetBytes(content); +EncryptResult encrypted = encryptClient.Encrypt(EncryptParameters.RsaOaepParameters(plaintext)); + +Debug.WriteLine($"Encrypted: {Encoding.UTF8.GetString(plaintext)}"); +``` + +## Decrypting with Key Vault or Managed HSM + +Because Key Vault and Managed HSM do not return the private key material, you can decrypt the ciphertext encrypted above +remotely in the Key Vault or Managed HSM. We'll get a `CryptographyClient` from our original `KeyClient` that shares +the same policy, including any customized pipeline policies, diagnostic information, and more. + +```C# Snippet:KeysSample7Decrypt +CryptographyClient decryptClient = keyClient.GetCryptographyClient(cloudRsaKey.Name, cloudRsaKey.Properties.Version); +DecryptResult decrypted = decryptClient.Decrypt(DecryptParameters.RsaOaepParameters(ciphertext)); + +Debug.WriteLine($"Decrypted: {Encoding.UTF8.GetString(decrypted.Plaintext)}"); +``` + +## Source + +To see the full example source, see: + +* [Synchronous Sample7_SerializeJsonWebKey.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKey.cs) +* [Asynchronous Sample7_SerializeJsonWebKeyAsync.cs](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKeyAsync.cs) + +[DefaultAzureCredential]: https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/README.md +[JWK]: https://datatracker.ietf.org/doc/html/rfc7517 +[README]: https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/keyvault/Azure.Security.KeyVault.Keys/README.md diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKey.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKey.cs index 20cdc2337028c..55dd73f898d63 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKey.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKey.cs @@ -8,6 +8,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Serialization; using Azure.Core; namespace Azure.Security.KeyVault.Keys @@ -17,6 +18,7 @@ namespace Azure.Security.KeyVault.Keys /// structure that represents a cryptographic key. /// For more information, see JSON Web Key (JWK). /// + [JsonConverter(typeof(JsonWebKeyConverter))] public class JsonWebKey : IJsonDeserializable, IJsonSerializable { private const string KeyIdPropertyName = "kid"; @@ -36,6 +38,7 @@ public class JsonWebKey : IJsonDeserializable, IJsonSerializable private const string KPropertyName = "k"; private const string TPropertyName = "key_hsm"; + private static readonly JsonEncodedText s_keyIdPropertyNameBytes = JsonEncodedText.Encode(KeyIdPropertyName); private static readonly JsonEncodedText s_keyTypePropertyNameBytes = JsonEncodedText.Encode(KeyTypePropertyName); private static readonly JsonEncodedText s_keyOpsPropertyNameBytes = JsonEncodedText.Encode(KeyOpsPropertyName); private static readonly JsonEncodedText s_curveNamePropertyNameBytes = JsonEncodedText.Encode(CurveNamePropertyName); @@ -428,8 +431,12 @@ internal void ReadProperties(JsonElement json) } } - internal void WriteProperties(Utf8JsonWriter json) + internal void WriteProperties(Utf8JsonWriter json, bool withId = false) { + if (Id != null && withId) + { + json.WriteString(s_keyIdPropertyNameBytes, Id); + } if (KeyType != default) { json.WriteString(s_keyTypePropertyNameBytes, KeyType.ToString()); diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKeyConverter.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKeyConverter.cs new file mode 100644 index 0000000000000..68ba7846c10b4 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/src/JsonWebKeyConverter.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; + +namespace Azure.Security.KeyVault.Keys +{ + /// + /// Converts a to or from JSON. + /// + internal sealed class JsonWebKeyConverter : JsonConverter + { + /// + public override JsonWebKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + + JsonWebKey value = new(); + value.ReadProperties(doc.RootElement); + + return value; + } + + /// + public override void Write(Utf8JsonWriter writer, JsonWebKey value, JsonSerializerOptions options) + { + Argument.AssertNotNull(writer, nameof(writer)); + Argument.AssertNotNull(value, nameof(value)); + + writer.WriteStartObject(); + value.WriteProperties(writer, withId: true); + writer.WriteEndObject(); + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/JsonWebKeyTests.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/JsonWebKeyTests.cs index c394d3e4a60a6..38f4a2f3006dc 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/JsonWebKeyTests.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/JsonWebKeyTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Azure.Core; using NUnit.Framework; @@ -42,7 +43,7 @@ public void SerializeOctet() JsonWebKey deserialized = new JsonWebKey(); deserialized.Deserialize(ms); - Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.s_instance)); + Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.Shared)); } [Test] @@ -140,7 +141,7 @@ public void SerializeECDsa(string oid, string friendlyName, bool includePrivateP JsonWebKey deserialized = new JsonWebKey(); deserialized.Deserialize(ms); - Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.s_instance)); + Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.Shared)); #endif } @@ -281,7 +282,7 @@ public void SerializeRSA(bool includePrivateParameters) JsonWebKey deserialized = new JsonWebKey(); deserialized.Deserialize(ms); - Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.s_instance)); + Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.Shared)); } [Test] @@ -357,6 +358,33 @@ public void ToRSAInvalidKey(RSAParameters rsaParameters, string name) Assert.Throws(() => jwk.ToRSA(), "Expected exception not thrown for data named '{0}'", name); } + [Test] + public void SerializesJwt() + { + using RSA rsa = RSA.Create(); + JsonWebKey jwk = new(rsa, true) + { + Id = "https://test.vault.azure.net/keys/test/abcd1234", + }; + + // Serialize + using MemoryStream ms = new(); + using (Utf8JsonWriter writer = new(ms)) + { + JsonSerializer.Serialize(writer, jwk); + } + + string content = Encoding.UTF8.GetString(ms.ToArray()); + StringAssert.Contains(@"""key_ops""", content); + StringAssert.Contains(@"""kid"":""https://test.vault.azure.net/keys/test/abcd1234""", content); + StringAssert.Contains(@"""kty"":""RSA""", content); + + // Deserialize + JsonWebKey deserialized = JsonSerializer.Deserialize(content); + + Assert.That(deserialized, Is.EqualTo(jwk).Using(JsonWebKeyComparer.Shared)); + } + private static IEnumerable GetECDSaTestData() { (string Oid, string FriendlyName)[] oids = new[] @@ -455,7 +483,7 @@ private static bool HasPrivateKey(JsonWebKey jwk) private class JsonWebKeyComparer : IEqualityComparer { - internal static readonly IEqualityComparer s_instance = new JsonWebKeyComparer(); + public static IEqualityComparer Shared { get; } = new JsonWebKeyComparer(); public bool Equals(JsonWebKey x, JsonWebKey y) { diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/KeyClientLiveTests.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/KeyClientLiveTests.cs index fe92c043273da..16adca76c9628 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/KeyClientLiveTests.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/KeyClientLiveTests.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Azure.Core.TestFramework; using Azure.Security.KeyVault.Tests; @@ -95,6 +97,14 @@ public async Task CreateEcHsmKey() KeyVaultKey keyReturned = await Client.GetKeyAsync(keyName); AssertKeyVaultKeysEqual(ecHsmkey, keyReturned); + + using MemoryStream ms = new(); + await JsonSerializer.SerializeAsync(ms, keyReturned.Key); + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains($@"""kid"":""{keyReturned.Id}""", json); + StringAssert.Contains(@"""kty"":""EC-HSM""", json); + StringAssert.Contains(@"""crv"":""P-256""", json); } [Test] @@ -149,6 +159,13 @@ public async Task CreateRsaHsmKey() KeyVaultKey keyReturned = await Client.GetKeyAsync(keyName); AssertKeyVaultKeysEqual(rsaHsmkey, keyReturned); + + using MemoryStream ms = new(); + await JsonSerializer.SerializeAsync(ms, keyReturned.Key); + string json = Encoding.UTF8.GetString(ms.ToArray()); + + StringAssert.Contains($@"""kid"":""{keyReturned.Id}""", json); + StringAssert.Contains(@"""kty"":""RSA-HSM""", json); } [Test] diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/SampleFixture.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/SampleFixture.cs index 39b5449a7ea68..f618ec495bcb8 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/SampleFixture.cs +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/SampleFixture.cs @@ -27,6 +27,7 @@ public partial class GetKeys : SampleFixture { } public partial class Sample4_EncryptDecypt : SampleFixture { } public partial class Sample5_SignVerify : SampleFixture { } public partial class Sample6_WrapUnwrap : SampleFixture { } + public partial class Sample7_SerializeJsonWebKey : SampleFixture { } public partial class Snippets : SampleFixture { } #pragma warning restore SA1402 // File may only contain a single type } diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKey.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKey.cs new file mode 100644 index 0000000000000..c5a98b0504786 --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKey.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Security.KeyVault.Keys.Cryptography; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.IO; +using System.Text.Json; + +namespace Azure.Security.KeyVault.Keys.Samples +{ + /// + /// Sample demonstrates how to serialize a JWK, use that to encrypt locally, and deserialize remotely using Key Vault. + /// + public partial class Sample7_SerializeJsonWebKey + { + [Test] + public void SerializeJsonWebKeySync() + { + // Environment variable with the Key Vault endpoint. + string keyVaultUrl = TestEnvironment.KeyVaultUrl; + + #region Snippet:KeysSample7KeyClient + var keyClient = new KeyClient(new Uri(keyVaultUrl), new DefaultAzureCredential()); + #endregion + + #region Snippet:KeysSample7CreateKey + string rsaKeyName = $"CloudRsaKey-{Guid.NewGuid()}"; + var rsaKey = new CreateRsaKeyOptions(rsaKeyName, hardwareProtected: false) + { + KeySize = 2048, + }; + + KeyVaultKey cloudRsaKey = keyClient.CreateRsaKey(rsaKey); + Debug.WriteLine($"Key is returned with name {cloudRsaKey.Name} and type {cloudRsaKey.KeyType}"); + #endregion + + string dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "samples", nameof(Sample7_SerializeJsonWebKey)); + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, $"{nameof(SerializeJsonWebKeySync)}.json"); + + // Use `using` expression for clean sample, but scope it to close and dispose immediately. + { + #region Snippet:KeysSample7Serialize + using FileStream file = File.Create(path); + using (Utf8JsonWriter writer = new Utf8JsonWriter(file)) + { + JsonSerializer.Serialize(writer, cloudRsaKey.Key); + } + + Debug.WriteLine($"Saved JWK to {path}"); + #endregion + } + + #region Snippet:KeysSamples7Deserialize + byte[] buffer = File.ReadAllBytes(path); + JsonWebKey jwk = JsonSerializer.Deserialize(buffer); + + Debug.WriteLine($"Read JWK from {path} with ID {jwk.Id}"); + #endregion + + string content = "plaintext"; + + #region Snippet:KeysSample7Encrypt + var encryptClient = new CryptographyClient(jwk); + + byte[] plaintext = Encoding.UTF8.GetBytes(content); + EncryptResult encrypted = encryptClient.Encrypt(EncryptParameters.RsaOaepParameters(plaintext)); + + Debug.WriteLine($"Encrypted: {Encoding.UTF8.GetString(plaintext)}"); + #endregion + + byte[] ciphertext = encrypted.Ciphertext; + + #region Snippet:KeysSample7Decrypt + CryptographyClient decryptClient = keyClient.GetCryptographyClient(cloudRsaKey.Name, cloudRsaKey.Properties.Version); + DecryptResult decrypted = decryptClient.Decrypt(DecryptParameters.RsaOaepParameters(ciphertext)); + + Debug.WriteLine($"Decrypted: {Encoding.UTF8.GetString(decrypted.Plaintext)}"); + #endregion + + DeleteKeyOperation operation = keyClient.StartDeleteKey(rsaKeyName); + + // You only need to wait for completion if you want to purge or recover the key. + while (!operation.HasCompleted) + { + Thread.Sleep(2000); + + operation.UpdateStatus(); + } + + // If the keyvault is soft-delete enabled, then for permanent deletion, deleted key needs to be purged. + keyClient.PurgeDeletedKey(rsaKeyName); + } + } +} diff --git a/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKeyAsync.cs b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKeyAsync.cs new file mode 100644 index 0000000000000..321b3e193f06b --- /dev/null +++ b/sdk/keyvault/Azure.Security.KeyVault.Keys/tests/samples/Sample7_SerializeJsonWebKeyAsync.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Security.KeyVault.Keys.Cryptography; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.Text; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Azure.Security.KeyVault.Keys.Samples +{ + public partial class Sample7_SerializeJsonWebKey + { + [Test] + public async Task SerializeJsonWebKeyAsync() + { + // Environment variable with the Key Vault endpoint. + string keyVaultUrl = TestEnvironment.KeyVaultUrl; + + var keyClient = new KeyClient(new Uri(keyVaultUrl), new DefaultAzureCredential()); + + string rsaKeyName = $"CloudRsaKey-{Guid.NewGuid()}"; + var rsaKey = new CreateRsaKeyOptions(rsaKeyName, hardwareProtected: false) + { + KeySize = 2048, + }; + + KeyVaultKey cloudRsaKey = await keyClient.CreateRsaKeyAsync(rsaKey); + Debug.WriteLine($"Key is returned with name {cloudRsaKey.Name} and type {cloudRsaKey.KeyType}"); + + string dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "samples", nameof(Sample7_SerializeJsonWebKey)); + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, $"{nameof(SerializeJsonWebKeyAsync)}.json"); + + // Use `using` expression for clean sample, but scope it to close and dispose immediately. + { + using FileStream file = File.Create(path); + await JsonSerializer.SerializeAsync(file, cloudRsaKey.Key); + + Debug.WriteLine($"Saved JWK to {path}"); + } + + // Use `using` expression for clean sample, but scope it to close and dispose immediately. + JsonWebKey jwk = null; + { + using FileStream file = File.Open(path, FileMode.Open); + jwk = await JsonSerializer.DeserializeAsync(file); + + Debug.WriteLine($"Read JWK from {path} with ID {jwk.Id}"); + } + + string content = "plaintext"; + + var encryptClient = new CryptographyClient(jwk); + + byte[] plaintext = Encoding.UTF8.GetBytes(content); + EncryptResult encrypted = await encryptClient.EncryptAsync(EncryptParameters.RsaOaepParameters(plaintext)); + + Debug.WriteLine($"Encrypted: {Encoding.UTF8.GetString(plaintext)}"); + + byte[] ciphertext = encrypted.Ciphertext; + + CryptographyClient decryptClient = keyClient.GetCryptographyClient(cloudRsaKey.Name, cloudRsaKey.Properties.Version); + DecryptResult decrypted = await decryptClient.DecryptAsync(DecryptParameters.RsaOaepParameters(ciphertext)); + + Debug.WriteLine($"Decrypted: {Encoding.UTF8.GetString(decrypted.Plaintext)}"); + + DeleteKeyOperation operation = await keyClient.StartDeleteKeyAsync(rsaKeyName); + + // You only need to wait for completion if you want to purge or recover the key. + await operation.WaitForCompletionAsync(); + + // If the keyvault is soft-delete enabled, then for permanent deletion, deleted key needs to be purged. + keyClient.PurgeDeletedKey(rsaKeyName); + } + } +}