diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/CHANGELOG.md b/sdk/confidentialledger/Azure.Security.CodeTransparency/CHANGELOG.md index e4fe2e225317..940f7da02ec0 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/CHANGELOG.md +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features Added +- A new option to pass transparent statement verification key sets mapped to domain names for offline verification using `CodeTransparencyVerificationOptions.OfflineKeys` +- A new option to restrict the use of a network resolution of the ledger keys when using `OfflineKeys` with `CodeTransparencyVerificationOptions.OfflineKeysBehavior` + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.net8.0.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.net8.0.cs index c3555b7a23f8..66916245164e 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.net8.0.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.net8.0.cs @@ -74,6 +74,14 @@ public enum ServiceVersion V2025_01_31_Preview = 1, } } + public sealed partial class CodeTransparencyOfflineKeys + { + public CodeTransparencyOfflineKeys() { } + public System.Collections.Generic.IReadOnlyDictionary ByIssuer { get { throw null; } } + public void Add(string ledgerDomain, Azure.Security.CodeTransparency.JwksDocument jwksDocument) { } + public static Azure.Security.CodeTransparency.CodeTransparencyOfflineKeys FromBinaryData(System.BinaryData json) { throw null; } + public System.BinaryData ToBinaryData() { throw null; } + } public enum CodeTransparencyOperationStatus { Running = 0, @@ -85,6 +93,8 @@ public sealed partial class CodeTransparencyVerificationOptions public CodeTransparencyVerificationOptions() { } public System.Collections.Generic.IList AuthorizedDomains { get { throw null; } set { } } public Azure.Security.CodeTransparency.AuthorizedReceiptBehavior AuthorizedReceiptBehavior { get { throw null; } set { } } + public Azure.Security.CodeTransparency.CodeTransparencyOfflineKeys OfflineKeys { get { throw null; } set { } } + public Azure.Security.CodeTransparency.OfflineKeysBehavior OfflineKeysBehavior { get { throw null; } set { } } public Azure.Security.CodeTransparency.UnauthorizedReceiptBehavior UnauthorizedReceiptBehavior { get { throw null; } set { } } } public partial class JsonWebKey : System.ClientModel.Primitives.IJsonModel, System.ClientModel.Primitives.IPersistableModel @@ -125,6 +135,11 @@ protected virtual void JsonModelWriteCore(System.Text.Json.Utf8JsonWriter writer string System.ClientModel.Primitives.IPersistableModel.GetFormatFromOptions(System.ClientModel.Primitives.ModelReaderWriterOptions options) { throw null; } System.BinaryData System.ClientModel.Primitives.IPersistableModel.Write(System.ClientModel.Primitives.ModelReaderWriterOptions options) { throw null; } } + public enum OfflineKeysBehavior + { + FallbackToNetwork = 0, + NoFallbackToNetwork = 1, + } public static partial class SecurityCodeTransparencyModelFactory { public static Azure.Security.CodeTransparency.JsonWebKey JsonWebKey(string alg = null, string crv = null, string d = null, string dp = null, string dq = null, string e = null, string k = null, string kid = null, string kty = null, string n = null, string p = null, string q = null, string qi = null, string use = null, string x = null, System.Collections.Generic.IEnumerable x5c = null, string y = null) { throw null; } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.netstandard2.0.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.netstandard2.0.cs index 0466f73cfade..a234075003b9 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.netstandard2.0.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/api/Azure.Security.CodeTransparency.netstandard2.0.cs @@ -74,6 +74,14 @@ public enum ServiceVersion V2025_01_31_Preview = 1, } } + public sealed partial class CodeTransparencyOfflineKeys + { + public CodeTransparencyOfflineKeys() { } + public System.Collections.Generic.IReadOnlyDictionary ByIssuer { get { throw null; } } + public void Add(string ledgerDomain, Azure.Security.CodeTransparency.JwksDocument jwksDocument) { } + public static Azure.Security.CodeTransparency.CodeTransparencyOfflineKeys FromBinaryData(System.BinaryData json) { throw null; } + public System.BinaryData ToBinaryData() { throw null; } + } public enum CodeTransparencyOperationStatus { Running = 0, @@ -85,6 +93,8 @@ public sealed partial class CodeTransparencyVerificationOptions public CodeTransparencyVerificationOptions() { } public System.Collections.Generic.IList AuthorizedDomains { get { throw null; } set { } } public Azure.Security.CodeTransparency.AuthorizedReceiptBehavior AuthorizedReceiptBehavior { get { throw null; } set { } } + public Azure.Security.CodeTransparency.CodeTransparencyOfflineKeys OfflineKeys { get { throw null; } set { } } + public Azure.Security.CodeTransparency.OfflineKeysBehavior OfflineKeysBehavior { get { throw null; } set { } } public Azure.Security.CodeTransparency.UnauthorizedReceiptBehavior UnauthorizedReceiptBehavior { get { throw null; } set { } } } public partial class JsonWebKey : System.ClientModel.Primitives.IJsonModel, System.ClientModel.Primitives.IPersistableModel @@ -125,6 +135,11 @@ protected virtual void JsonModelWriteCore(System.Text.Json.Utf8JsonWriter writer string System.ClientModel.Primitives.IPersistableModel.GetFormatFromOptions(System.ClientModel.Primitives.ModelReaderWriterOptions options) { throw null; } System.BinaryData System.ClientModel.Primitives.IPersistableModel.Write(System.ClientModel.Primitives.ModelReaderWriterOptions options) { throw null; } } + public enum OfflineKeysBehavior + { + FallbackToNetwork = 0, + NoFallbackToNetwork = 1, + } public static partial class SecurityCodeTransparencyModelFactory { public static Azure.Security.CodeTransparency.JsonWebKey JsonWebKey(string alg = null, string crv = null, string d = null, string dp = null, string dq = null, string e = null, string k = null, string kid = null, string kty = null, string n = null, string p = null, string q = null, string qi = null, string use = null, string x = null, System.Collections.Generic.IEnumerable x5c = null, string y = null) { throw null; } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/samples/Sample2_ReceiptDownloadVerification.md b/sdk/confidentialledger/Azure.Security.CodeTransparency/samples/Sample2_ReceiptDownloadVerification.md index 60baaf15f8c2..a8a08d26d3e7 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/samples/Sample2_ReceiptDownloadVerification.md +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/samples/Sample2_ReceiptDownloadVerification.md @@ -83,3 +83,46 @@ catch (Exception e) } ``` +### Offline verification using stored keys + +In cases where you have downloaded the keys ahead of time, it is possible to use them to perform verification without the +network calls to download them from the ledger. + +**First, you would need to have the keys stored:** + +```C# Snippet:CodeTransparencyVerification_StoreForOfflineUse +CodeTransparencyClient client = new(new Uri("https://<< service name >>.confidential-ledger.azure.com")); +// Download the transparent statement +Response transparentStatementResponse = client.GetEntryStatement("4.44"); +string filePath = Path.Combine(Path.GetTempPath(), "transparent_statement.cose"); +File.WriteAllBytes(filePath, transparentStatementResponse.Value.ToArray()); +// Download and store the public keys for offline verification +Response ledgerKeys = client.GetPublicKeys(); +CodeTransparencyOfflineKeys allKeys = new(); +allKeys.Add("<< service name >>.confidential-ledger.azure.com", ledgerKeys.Value); +string keysFilePath = Path.Combine(Path.GetTempPath(), "ledger_keys.json"); +File.WriteAllBytes(keysFilePath, allKeys.ToBinaryData().ToArray()); +``` + +**Then, read the files and verify:** + +```C# Snippet:CodeTransparencyVerification_Offline +var transparentStatement = File.ReadAllBytes(filePath); +var keys = File.ReadAllBytes(keysFilePath); +try +{ + var verificationOptions = new CodeTransparencyVerificationOptions + { + UnauthorizedReceiptBehavior = UnauthorizedReceiptBehavior.VerifyAll, + OfflineKeys = CodeTransparencyOfflineKeys.FromBinaryData(BinaryData.FromBytes(keys)), + OfflineKeysBehavior = OfflineKeysBehavior.NoFallbackToNetwork + }; + CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions); + + Console.WriteLine("Verification succeeded: The statement was registered in the immutable ledger."); +} +catch (Exception e) +{ + Console.WriteLine($"Verification failed: {e.Message}"); +} +``` diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index b3ae8e42c7a0..5482adc791ee 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -32,6 +32,16 @@ public partial class CodeTransparencyClient /// public static readonly string UnknownIssuerPrefix = "__unknown-issuer::"; + /// + /// Public key storage used to verify receipts. The value can be set through the verification options. + /// + private IReadOnlyDictionary _offlineKeys = null; + + /// + /// Indicates whether offline keys can fallback to network retrieval when a key is not found locally. + /// + private bool _offlineKeysAllowNetworkFallback = true; + /// /// Initializes a new instance of CodeTransparencyClient. The client will download its own /// TLS CA cert to perform server cert authentication. @@ -345,7 +355,6 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig Dictionary clientInstances = new Dictionary(); - // Prepare authorized list state. If no authorized list provided, implicitly authorized all detected issuer domains encountered in receipts. var configuredAuthorizedList = verificationOptions?.AuthorizedDomains ?? Array.Empty(); bool userProvidedAuthorizedList = configuredAuthorizedList.Count > 0; HashSet authorizedListNormalized = new(StringComparer.OrdinalIgnoreCase); @@ -360,6 +369,12 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig } } + // if no authorized domains are provided and the remaining ones are set to be ignored, then all receipts would be ignored + if (authorizedListNormalized.Count == 0 && verificationOptions.UnauthorizedReceiptBehavior == UnauthorizedReceiptBehavior.IgnoreAll) + { + throw new InvalidOperationException("No receipts would be verified as no authorized domains were provided and the unauthorized receipt behavior is set to ignore all."); + } + // Tracking for behaviors HashSet validAuthorizedDomainsEncountered = new(StringComparer.OrdinalIgnoreCase); HashSet authorizedDomainsWithReceipt = new(StringComparer.OrdinalIgnoreCase); @@ -420,10 +435,16 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig if (!clientInstances.TryGetValue(issuer, out CodeTransparencyClient clientInstance)) { clientInstance = new CodeTransparencyClient(new Uri($"https://{issuer}"), clientOptions); + if (verificationOptions?.OfflineKeys != null) + { + clientInstance._offlineKeys = verificationOptions.OfflineKeys.ByIssuer; + clientInstance._offlineKeysAllowNetworkFallback = verificationOptions.OfflineKeysBehavior == OfflineKeysBehavior.FallbackToNetwork; + } clientInstances[issuer] = clientInstance; } clientInstance.RunTransparentStatementVerification(transparentStatementCoseSign1Bytes, receiptBytes); + // If we reach here, verification succeeded if (isAuthorized) { validAuthorizedDomainsEncountered.Add(issuer); @@ -446,7 +467,7 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig switch (verificationOptions.AuthorizedReceiptBehavior) { case AuthorizedReceiptBehavior.VerifyAnyMatching: - if (validAuthorizedDomainsEncountered.Count == 0) + if (authorizedListNormalized.Count > 0 && validAuthorizedDomainsEncountered.Count == 0) { authorizedFailures.Add(new InvalidOperationException("No valid receipts found for any authorized issuer domain.")); } @@ -458,7 +479,7 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig break; case AuthorizedReceiptBehavior.VerifyAllMatching: // All receipts from authorized domains must be valid: i.e., any receipt from an authorized domain that failed adds failure (already captured) -> if any authorized domain had receipt but not all successful? We check failures now. - if (authorizedDomainsWithReceipt.Count == 0) + if (authorizedListNormalized.Count > 0 && authorizedDomainsWithReceipt.Count == 0) { authorizedFailures.Add(new InvalidOperationException("No valid receipts found for any authorized issuer domain.")); } @@ -508,8 +529,19 @@ private JsonWebKey GetServiceCertificateKey(byte[] receiptBytes) throw new InvalidOperationException("Issuer and service instance name are not matching."); } - // Get all the public keys from the JWKS endpoint - JwksDocument jwksDocument = GetPublicKeys().Value; + JwksDocument jwksDocument = null; + // Check if we have offline keys for this domain + if (_offlineKeys?.TryGetValue(issuer, out jwksDocument) != true && _offlineKeysAllowNetworkFallback) + { + // Get all the public keys from the JWKS endpoint + jwksDocument = GetPublicKeys().Value; + } + + // Ensure jwksDocument was obtained from either offline keys or network + if (jwksDocument == null) + { + throw new InvalidOperationException($"No keys available for issuer '{issuer}'. Either offline keys are not configured or network fallback is disabled."); + } // Ensure there is at least one entry in the JWKS document if (jwksDocument.Keys.Count == 0) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClientOptions.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClientOptions.cs index 5c4c3b3c5ea5..d498dd706b63 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClientOptions.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClientOptions.cs @@ -20,6 +20,7 @@ public partial class CodeTransparencyClientOptions : ClientOptions /// The default identity service endpoint. /// public string IdentityClientEndpoint { get; set; } = "https://identity.confidential-ledger.core.azure.com/"; + /// /// Used in the regular client constructor. /// Creates the used to get the identity service certificate. diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs new file mode 100644 index 000000000000..840234efdb51 --- /dev/null +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json; +using Azure.Core; + +namespace Azure.Security.CodeTransparency +{ + /// + /// A case-insensitive dictionary mapping ledger domains to their JWKS documents for offline verification. + /// + public sealed class CodeTransparencyOfflineKeys + { + private IDictionary _keysByIssuer; + + /// + /// Initializes a new instance of CodeTransparencyOfflineKeys. + /// + public CodeTransparencyOfflineKeys() + { + _keysByIssuer = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the dictionary of ledger domains to their JWKS documents. + /// + public IReadOnlyDictionary ByIssuer => new ReadOnlyDictionary(_keysByIssuer); + + /// + /// Adds or updates a JWKS document for the specified ledger domain. + /// + public void Add(string ledgerDomain, JwksDocument jwksDocument) + { + Argument.AssertNotNullOrEmpty(ledgerDomain, nameof(ledgerDomain)); + Argument.AssertNotNull(jwksDocument, nameof(jwksDocument)); + _keysByIssuer[ledgerDomain] = jwksDocument; + } + + /// + /// Creates a CodeTransparencyOfflineKeys instance from a BinaryData containing JSON. + /// + public static CodeTransparencyOfflineKeys FromBinaryData(BinaryData json) + { + using (JsonDocument doc = JsonDocument.Parse(json.ToString())) + { + return FromJsonDocument(doc); + } + } + + /// + /// Serializes the CodeTransparencyOfflineKeys to JSON bytes. + /// + public BinaryData ToBinaryData() + { + using (var stream = new System.IO.MemoryStream()) + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (var kvp in _keysByIssuer) + { + writer.WritePropertyName(kvp.Key); + writer.WriteObjectValue(kvp.Value); + } + writer.WriteEndObject(); + writer.Flush(); + return new BinaryData(stream.ToArray()); + } + } + + internal static CodeTransparencyOfflineKeys FromJsonDocument(JsonDocument jsonDocument) + { + return DeserializeKeys(jsonDocument.RootElement); + } + + internal static CodeTransparencyOfflineKeys DeserializeKeys(JsonElement element, ModelReaderWriterOptions options = null) + { + var keys = new CodeTransparencyOfflineKeys(); + + foreach (var property in element.EnumerateObject()) + { + var ledgerDomain = property.Name; + var jwksDocument = JwksDocument.DeserializeJwksDocument(property.Value, options); + keys.Add(ledgerDomain, jwksDocument); + } + + return keys; + } + } +} diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs index cdfcb1be8c28..e08aa79c9222 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs @@ -24,6 +24,7 @@ public enum AuthorizedReceiptBehavior /// RequireAll = 2 } + /// /// Specifies behaviors for receipts whose issuer domains are not contained in . /// @@ -43,6 +44,21 @@ public enum UnauthorizedReceiptBehavior FailIfPresent = 2 } + /// + /// Specifies behaviors for the use of offline keys contained in . + /// + public enum OfflineKeysBehavior + { + /// + /// Use offline keys when available, but fall back to network retrieval if no offline key is found for a given ledger domain. + /// + FallbackToNetwork = 0, + /// + /// Use only offline keys. If no offline key is found for a given ledger domain, verification fails. + /// + NoFallbackToNetwork = 1 + } + /// /// Options controlling . /// @@ -63,7 +79,7 @@ public CodeTransparencyVerificationOptions() /// /// Gets or sets the behavior for receipts whose issuer domain is not in . - /// Defaults to to preserve current behavior. + /// Defaults to . /// public UnauthorizedReceiptBehavior UnauthorizedReceiptBehavior { get; set; } = UnauthorizedReceiptBehavior.FailIfPresent; @@ -72,5 +88,17 @@ public CodeTransparencyVerificationOptions() /// Defaults to . /// public AuthorizedReceiptBehavior AuthorizedReceiptBehavior { get; set; } = AuthorizedReceiptBehavior.VerifyAllMatching; + + /// + /// Gets or sets a store mapping ledger domains to JWKS documents for offline verification. + /// When provided, will skip network calls and use the matching JWKS document from this store instead. + /// + public CodeTransparencyOfflineKeys OfflineKeys { get; set; } = null; + + /// + /// Gets or sets the behavior for using offline keys in . + /// Defaults to . + /// + public OfflineKeysBehavior OfflineKeysBehavior { get; set; } = OfflineKeysBehavior.FallbackToNetwork; } } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs index 7ef7d815cdea..5fdf7c1e81b1 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Security.Cryptography; +using System.Text.Json; using System.Threading.Tasks; using Azure.Core.TestFramework; using NUnit.Framework; @@ -27,6 +28,42 @@ public class CodeTransparencyClientUnitTests : ClientTestBase } """; + private readonly string ValidSignedStatementJWKS = + "{\"keys\":" + + "[{\"crv\": \"P-384\"," + + "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + + "\"kty\":\"EC\"," + + "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + + "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + + "}]}"; + + private readonly string InvalidSignedStatementJWKSWithWrongKid = + "{\"keys\":" + + "[{\"crv\": \"P-384\"," + + "\"kid\":\"99954f9b6272971320c95850f74a9459c283b375531173c3d5d9bfd5822163cb\"," + + "\"kty\":\"EC\"," + + "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + + "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + + "}]}"; + + private readonly string InvalidSignedStatementJWKSWithWrongCurve = + "{\"keys\":" + + "[{\"crv\": \"P-512\"," + + "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + + "\"kty\":\"EC\"," + + "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + + "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + + "}]}"; + + private readonly string InvalidSignedStatementJWKSWithWrongParams = + "{\"keys\":" + + "[{\"crv\": \"P-384\"," + + "\"kid\":\"1dd54f9b6272971320c95850f74a9459c283b375531173c3d5d9bfd5822163cb\"," + + "\"kty\":\"EC\"," + + "\"x\": \"WAHDpC-ECgc7LvCxlaOPsY-xVYF9iStcEPU3XGF8dlhtb6dMHZSYVPMs2gliK-gc\"," + + "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + + "}]}"; + private byte[] readFileBytes(string name) { var assembly = Assembly.GetExecutingAssembly(); @@ -60,52 +97,28 @@ private MockResponse createValidCanaryIdentityResponse() private MockResponse createValidSignedStatementPublicKeyResponse() { var content = new MockResponse(200); - content.SetContent("{\"keys\":" + - "[{\"crv\": \"P-384\"," + - "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + - "\"kty\":\"EC\"," + - "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + - "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + - "}]}"); + content.SetContent(ValidSignedStatementJWKS); return content; } private MockResponse createInvalidSignedStatementPublicKeyResponseWithWrongKid() { var content = new MockResponse(200); - content.SetContent("{\"keys\":" + - "[{\"crv\": \"P-384\"," + - "\"kid\":\"99954f9b6272971320c95850f74a9459c283b375531173c3d5d9bfd5822163cb\"," + - "\"kty\":\"EC\"," + - "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + - "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + - "}]}"); + content.SetContent(InvalidSignedStatementJWKSWithWrongKid); return content; } private MockResponse createInvalidSignedStatementPublicKeyResponseWithWrongCurve() { var content = new MockResponse(200); - content.SetContent("{\"keys\":" + - "[{\"crv\": \"P-512\"," + - "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + - "\"kty\":\"EC\"," + - "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + - "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + - "}]}"); + content.SetContent(InvalidSignedStatementJWKSWithWrongCurve); return content; } private MockResponse createInvalidSignedStatementPublicKeyResponseWithWrongParams() { var content = new MockResponse(200); - content.SetContent("{\"keys\":" + - "[{\"crv\": \"P-384\"," + - "\"kid\":\"1dd54f9b6272971320c95850f74a9459c283b375531173c3d5d9bfd5822163cb\"," + - "\"kty\":\"EC\"," + - "\"x\": \"WAHDpC-ECgc7LvCxlaOPsY-xVYF9iStcEPU3XGF8dlhtb6dMHZSYVPMs2gliK-gc\"," + - "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + - "}]}"); + content.SetContent(InvalidSignedStatementJWKSWithWrongParams); return content; } @@ -116,7 +129,7 @@ private MockResponse createInvalidSignedStatementPublicKeyResponseWithWrongParam var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; return (mockTransport, options); } @@ -132,7 +145,7 @@ public void CodeTransparencyClient_constructor_does_not_request_to_get_cert() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var _ = new CodeTransparencyClient(new Uri("https://foo.bar.com"), null, options); Assert.AreEqual(0, mockTransport.Requests.Count); @@ -162,7 +175,7 @@ public async Task CreateEntryAsync_sendsBytes_receives_bytes() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; CodeTransparencyClient client = new(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); @@ -197,7 +210,7 @@ public async Task CreateEntryAsync_request_accepted() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; CodeTransparencyClient client = new(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); @@ -231,7 +244,7 @@ public async Task CreateEntryAsync_unsuccessful_post_success_after_retry() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); BinaryData content = BinaryData.FromString("Hello World!"); @@ -290,7 +303,7 @@ public async Task CreateEntryAsync_waits_for_operation_success() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; CodeTransparencyClient client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); @@ -347,7 +360,7 @@ public void CreateEntry_ShouldReturnResponse() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; CodeTransparencyClient client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); @@ -367,7 +380,7 @@ public async Task GetEntryAsync_gets_entry_bytes_after_retry() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); Response response = await client.GetEntryAsync("4.44"); @@ -386,7 +399,7 @@ public async Task GetEntryAsync_gets_entry_bytes() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); Response response = await client.GetEntryAsync("4.44"); @@ -404,7 +417,7 @@ public async Task GetTransparencyConfigCborAsync_ShouldReturnResponse() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); @@ -423,7 +436,7 @@ public void GetPublicKeys_Success_After_retry() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var client = new CodeTransparencyClient(new Uri("https://foo.bar.com"), new AzureKeyCredential("token"), options); @@ -445,7 +458,7 @@ public void VerifyTransparentStatement_InvalidParameters_ShouldThrowCryptographi var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var verificationOptions = new CodeTransparencyVerificationOptions { @@ -474,6 +487,105 @@ public void VerifyTransparentStatement_success() #endif } + [Test] + public void VerifyTransparentStatement_offline_success() + { +#if NET462 + Assert.Ignore("JsonWebKey to ECDsa is not supported on net462."); +#else + // Parse the JWKS JSON from the mocked response + string doc = "{\"foo.bar.com\":" + ValidSignedStatementJWKS + "}"; + using (var jsonDoc = JsonDocument.Parse(doc)) + { + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); + + var mockTransport = new MockTransport(new MockResponse(503)); + var options = new CodeTransparencyClientOptions + { + IdentityClientEndpoint = "https://some.identity.com", + Transport = mockTransport, + }; + + var verificationOptions = new CodeTransparencyVerificationOptions + { + AuthorizedDomains = new string[] { "foo.bar.com" }, + OfflineKeys = offlineStore + }; + + byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); + + // Should not make any network calls since we're using offline keys + CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions, options); + + Assert.AreEqual(0, mockTransport.Requests.Count); + } +#endif + } + + [Test] + public void VerifyTransparentStatement_offline_success_with_fallback() + { +#if NET462 + Assert.Ignore("JsonWebKey to ECDsa is not supported on net462."); +#else + // Parse the JWKS JSON from the mocked response + string doc = "{}"; + using (var jsonDoc = JsonDocument.Parse(doc)) + { + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); + + var (mockTransport, options) = createClientOptionsWithValidPublicKeyResponse(); + + var verificationOptions = new CodeTransparencyVerificationOptions + { + AuthorizedDomains = new string[] { "foo.bar.com" }, + OfflineKeys = offlineStore + }; + + byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); + + // Offline keys are empty, so network fallback is expected; should make 1 network call + CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions, options); + + Assert.AreEqual(1, mockTransport.Requests.Count); + } +#endif + } + + [Test] + public void VerifyTransparentStatement_offline_failure_without_network_fallback() + { +#if NET462 + Assert.Ignore("JsonWebKey to ECDsa is not supported on net462."); +#else + // Parse the JWKS JSON from the mocked response + string doc = "{}"; + using (var jsonDoc = JsonDocument.Parse(doc)) + { + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); + + var mockTransport = new MockTransport(new MockResponse(503)); + var options = new CodeTransparencyClientOptions + { + IdentityClientEndpoint = "https://some.identity.com", + Transport = mockTransport, + }; + + var verificationOptions = new CodeTransparencyVerificationOptions + { + AuthorizedDomains = new string[] { "foo.bar.com" }, + OfflineKeys = offlineStore, + OfflineKeysBehavior = OfflineKeysBehavior.NoFallbackToNetwork + }; + + byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); + var exception = Assert.Throws(() => CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions, options)); + StringAssert.Contains("Either offline keys are not configured or network fallback is disabled.", exception.Message); + Assert.AreEqual(0, mockTransport.Requests.Count); + } +#endif + } + [Test] public void VerifyTransparentStatement_InvalidCurve_InvalidOperationException() { @@ -485,7 +597,7 @@ public void VerifyTransparentStatement_InvalidCurve_InvalidOperationException() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var verificationOptions = new CodeTransparencyVerificationOptions { @@ -509,7 +621,7 @@ public void VerifyTransparentStatement_Invalidkid_InvalidOperationException() var options = new CodeTransparencyClientOptions { Transport = mockTransport, - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com" }; var verificationOptions = new CodeTransparencyVerificationOptions { diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs new file mode 100644 index 000000000000..7e9f3be2e215 --- /dev/null +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Core; +using Azure.Security.CodeTransparency; +using NUnit.Framework; + +namespace Azure.Security.CodeTransparency.Tests +{ + public class CodeTransparencyOfflineKeysTest + { + private sealed class DummyJwksDocument : JwksDocument + { + private readonly string _id; + + public DummyJwksDocument(string id) + { + _id = id; + } + + public string Id => _id; + } + + [Test] + public void Constructor_InitializesEmptyDictionary() + { + var keys = new CodeTransparencyOfflineKeys(); + + Assert.IsNotNull(keys.ByIssuer); + Assert.AreEqual(0, keys.ByIssuer.Count); + } + + [Test] + public void Add_AddsNewEntry() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc = new DummyJwksDocument("doc1"); + + keys.Add("ledger1", doc); + + Assert.AreEqual(1, keys.ByIssuer.Count); + Assert.AreSame(doc, keys.ByIssuer["ledger1"]); + } + + [Test] + public void Add_UpdatesExistingEntry() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc1 = new DummyJwksDocument("doc1"); + var doc2 = new DummyJwksDocument("doc2"); + + keys.Add("ledger1", doc1); + keys.Add("ledger1", doc2); + + Assert.AreEqual(1, keys.ByIssuer.Count); + Assert.AreSame(doc2, keys.ByIssuer["ledger1"]); + } + + [Test] + public void Add_IsCaseInsensitiveOnDomain() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc1 = new DummyJwksDocument("doc1"); + var doc2 = new DummyJwksDocument("doc2"); + + keys.Add("Ledger.Domain", doc1); + keys.Add("ledger.domain", doc2); + + Assert.AreEqual(1, keys.ByIssuer.Count); + Assert.AreSame(doc2, keys.ByIssuer["LEDGER.DOMAIN"]); + } + + [Test] + public void Add_ThrowsOnNullLedgerDomain() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc = new DummyJwksDocument("doc"); + + Assert.Throws(() => keys.Add(null, doc)); + } + + [Test] + public void Add_ThrowsOnEmptyLedgerDomain() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc = new DummyJwksDocument("doc"); + + Assert.Throws(() => keys.Add(string.Empty, doc)); + } + + [Test] + public void Add_ThrowsOnNullJwksDocument() + { + var keys = new CodeTransparencyOfflineKeys(); + + Assert.Throws(() => keys.Add("ledger1", null)); + } + + [Test] + public void ByIssuer_ReturnsReadOnlyDictionary() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc = new DummyJwksDocument("doc1"); + keys.Add("ledger1", doc); + + var byIssuer = keys.ByIssuer; + + // IReadOnlyDictionary is read-only by design + Assert.Throws(() => + { + var cast = (IDictionary)byIssuer; + cast["ledger2"] = doc; + }); + } + + [Test] + public void FromBinaryData_ParsesMultipleEntries() + { + var json = @" + { + ""ledger1.contoso.com"": {}, + ""ledger2.contoso.com"": {} + }"; + + // We only care that JwksDocument.DeserializeJwksDocument is called for each entry. + // Since we cannot easily assert the internal JwksDocument instances without + // relying on its implementation, we focus on the key count. + var binary = new BinaryData(json); + + var keys = CodeTransparencyOfflineKeys.FromBinaryData(binary); + + Assert.AreEqual(2, keys.ByIssuer.Count); + Assert.IsTrue(keys.ByIssuer.ContainsKey("ledger1.contoso.com")); + Assert.IsTrue(keys.ByIssuer.ContainsKey("ledger2.contoso.com")); + } + + [Test] + public void DeserializeKeys_UsesPropertyNamesAsLedgerDomains() + { + var json = @" + { + ""ledger1.example.com"": {}, + ""ledger2.example.com"": {} + }"; + + using var doc = JsonDocument.Parse(json); + var keys = CodeTransparencyOfflineKeys.DeserializeKeys(doc.RootElement); + + Assert.AreEqual(2, keys.ByIssuer.Count); + Assert.IsTrue(keys.ByIssuer.ContainsKey("ledger1.example.com")); + Assert.IsTrue(keys.ByIssuer.ContainsKey("ledger2.example.com")); + } + } +} \ No newline at end of file diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/SamplesUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/SamplesUnitTests.cs index 767900265f90..020709413da8 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/SamplesUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/SamplesUnitTests.cs @@ -136,6 +136,90 @@ public async Task Snippet_Readme_CodeTransparencySubmission_Test() #endregion Snippet:CodeTransparencySample1_DownloadStatement } + [Test] + public void Snippet_Sample2_VerifyTransparentStatement_offline_success() + { +#if NET462 + Assert.Ignore("JsonWebKey to ECDsa is not supported on net462."); +#else + byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); + + // mock first request to get the entry statement + var mockedEntryResponse = new MockResponse(200); + mockedEntryResponse.AddHeader("Content-Type", "application/cose"); + mockedEntryResponse.SetContent(transparentStatementBytes); + + // mock second call to download the public keys + var mockedKeysResponse = new MockResponse(200); + mockedKeysResponse.SetContent("{\"keys\":" + + "[{\"crv\": \"P-384\"," + + "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + + "\"kty\":\"EC\"," + + "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + + "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + + "}]}"); + + var mockTransport = new MockTransport(mockedEntryResponse, mockedKeysResponse); + var options = new CodeTransparencyClientOptions + { + Transport = mockTransport, + IdentityClientEndpoint = "https://some.identity.com" + }; + + #region Snippet:CodeTransparencyVerification_StoreForOfflineUse +#if !SNIPPET + CodeTransparencyClient client = new(new Uri("https://foo.bar.com"), options); +#endif +#if SNIPPET + CodeTransparencyClient client = new(new Uri("https://<< service name >>.confidential-ledger.azure.com")); +#endif + // Download the transparent statement + Response transparentStatementResponse = client.GetEntryStatement("4.44"); + string filePath = Path.Combine(Path.GetTempPath(), "transparent_statement.cose"); + File.WriteAllBytes(filePath, transparentStatementResponse.Value.ToArray()); + // Download and store the public keys for offline verification + Response ledgerKeys = client.GetPublicKeys(); + CodeTransparencyOfflineKeys allKeys = new(); +#if !SNIPPET + allKeys.Add("foo.bar.com", ledgerKeys.Value); +#endif +#if SNIPPET + allKeys.Add("<< service name >>.confidential-ledger.azure.com", ledgerKeys.Value); +#endif + string keysFilePath = Path.Combine(Path.GetTempPath(), "ledger_keys.json"); + File.WriteAllBytes(keysFilePath, allKeys.ToBinaryData().ToArray()); + + #endregion Snippet:CodeTransparencyVerification_StoreForOfflineUse + + Assert.AreEqual(2, mockTransport.Requests.Count); + + #region Snippet:CodeTransparencyVerification_Offline + var transparentStatement = File.ReadAllBytes(filePath); + var keys = File.ReadAllBytes(keysFilePath); +#if SNIPPET + try + { +#endif + var verificationOptions = new CodeTransparencyVerificationOptions + { + UnauthorizedReceiptBehavior = UnauthorizedReceiptBehavior.VerifyAll, + OfflineKeys = CodeTransparencyOfflineKeys.FromBinaryData(BinaryData.FromBytes(keys)), + OfflineKeysBehavior = OfflineKeysBehavior.NoFallbackToNetwork + }; + CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions); +#if SNIPPET + + Console.WriteLine("Verification succeeded: The statement was registered in the immutable ledger."); + } + catch (Exception e) + { + Console.WriteLine($"Verification failed: {e.Message}"); + } +#endif + #endregion Snippet:CodeTransparencyVerification_Offline +#endif + } + [Test] public void Snippet_Sample3_Test() {