From f95d55493089dbbb62e2d4268ab0796d637f10fd Mon Sep 17 00:00:00 2001 From: Ivar Date: Mon, 1 Dec 2025 19:39:11 +0000 Subject: [PATCH 01/15] New verification option to provide CodeTransparencyVerificationKeys --- .../src/CodeTransparencyClient.cs | 21 ++++++- .../src/CodeTransparencyClientOptions.cs | 1 + .../src/CodeTransparencyVerificationKeys.cs | 62 +++++++++++++++++++ .../CodeTransparencyVerificationOptions.cs | 6 ++ .../tests/CodeTransparencyClientUnitTests.cs | 36 +++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index b3ae8e42c7a0..021ed2b02429 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -3,10 +3,14 @@ using System; using System.Collections.Generic; +using System.Collections.Concurrent; using System.Formats.Cbor; +using System.IO; using System.Security.Cryptography.Cose; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Azure.Core; @@ -32,6 +36,11 @@ public partial class CodeTransparencyClient /// public static readonly string UnknownIssuerPrefix = "__unknown-issuer::"; + /// + /// Public key storage used to verify receipts. It can be prepopulated to do offline verification. + /// + private IDictionary _verificationKeysCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + /// /// Initializes a new instance of CodeTransparencyClient. The client will download its own /// TLS CA cert to perform server cert authentication. @@ -420,6 +429,10 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig if (!clientInstances.TryGetValue(issuer, out CodeTransparencyClient clientInstance)) { clientInstance = new CodeTransparencyClient(new Uri($"https://{issuer}"), clientOptions); + if (verificationOptions?.CodeTransparencyVerificationKeys != null) + { + clientInstance._verificationKeysCache = verificationOptions.CodeTransparencyVerificationKeys.SerializedKeys; + } clientInstances[issuer] = clientInstance; } clientInstance.RunTransparentStatementVerification(transparentStatementCoseSign1Bytes, receiptBytes); @@ -508,8 +521,12 @@ 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; + // Check if we have cached keys for this domain + if (! _verificationKeysCache.TryGetValue(issuer, out JwksDocument jwksDocument)) + { + // Get all the public keys from the JWKS endpoint + jwksDocument = GetPublicKeys().Value; + } // 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/CodeTransparencyVerificationKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs new file mode 100644 index 000000000000..a674249b52f3 --- /dev/null +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; + +namespace Azure.Security.CodeTransparency +{ + /// + /// A case-insensitive dictionary mapping ledger domains to their JWKS documents for offline verification. + /// + public sealed class CodeTransparencyVerificationKeys + { + private IDictionary _serializedKeys; + + /// + /// Initializes a new instance of CodeTransparencyVerificationKeys. + /// + public CodeTransparencyVerificationKeys() + { + _serializedKeys = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the dictionary of ledger domains to their JWKS documents. + /// + public IDictionary SerializedKeys => _serializedKeys; + + /// + /// Adds or updates a JWKS document for the specified ledger domain. + /// + public void Add(string ledgerDomain, JwksDocument jwksDocument) + { + _serializedKeys[ledgerDomain] = jwksDocument; + } + + internal static CodeTransparencyVerificationKeys FromJsonDocument(JsonDocument jsonDocument) + { + return DeserializeKeys(jsonDocument.RootElement); + } + + internal static CodeTransparencyVerificationKeys DeserializeKeys(JsonElement element, ModelReaderWriterOptions options = null) + { + var keys = new CodeTransparencyVerificationKeys(); + + 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..95fc8f581469 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs @@ -72,5 +72,11 @@ 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 CodeTransparencyVerificationKeys CodeTransparencyVerificationKeys { get; set; } = null; } } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs index 7ef7d815cdea..bde8f64955a8 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; @@ -474,6 +475,41 @@ public void VerifyTransparentStatement_success() #endif } + [Test] + public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore() + { +#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\":{\"keys\":" + + "[{\"crv\": \"P-384\"," + + "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + + "\"kty\":\"EC\"," + + "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + + "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + + "}]}}"; + var jsonDoc = JsonDocument.Parse(doc); + var offlineStore = CodeTransparencyVerificationKeys.FromJsonDocument(jsonDoc); + + var options = new CodeTransparencyClientOptions + { + IdentityClientEndpoint = "https://foo.bar.com" + }; + + var verificationOptions = new CodeTransparencyVerificationOptions + { + AuthorizedDomains = new string[] { "foo.bar.com" }, + CodeTransparencyVerificationKeys = 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); +#endif + } + [Test] public void VerifyTransparentStatement_InvalidCurve_InvalidOperationException() { From be0b5acf84ae575d55f790c9dcf30aa1dbb9ac30 Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 10:51:10 +0000 Subject: [PATCH 02/15] emphasise that keys are offline --- .../src/CodeTransparencyClient.cs | 10 +++++----- .../src/CodeTransparencyVerificationKeys.cs | 20 +++++++++---------- .../CodeTransparencyVerificationOptions.cs | 2 +- .../tests/CodeTransparencyClientUnitTests.cs | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index 021ed2b02429..46ae81cf6aee 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -39,7 +39,7 @@ public partial class CodeTransparencyClient /// /// Public key storage used to verify receipts. It can be prepopulated to do offline verification. /// - private IDictionary _verificationKeysCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private IDictionary _offlineKeys = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// /// Initializes a new instance of CodeTransparencyClient. The client will download its own @@ -429,9 +429,9 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig if (!clientInstances.TryGetValue(issuer, out CodeTransparencyClient clientInstance)) { clientInstance = new CodeTransparencyClient(new Uri($"https://{issuer}"), clientOptions); - if (verificationOptions?.CodeTransparencyVerificationKeys != null) + if (verificationOptions?.CodeTransparencyOfflineKeys != null) { - clientInstance._verificationKeysCache = verificationOptions.CodeTransparencyVerificationKeys.SerializedKeys; + clientInstance._offlineKeys = verificationOptions.CodeTransparencyOfflineKeys.ByDomain; } clientInstances[issuer] = clientInstance; } @@ -521,8 +521,8 @@ private JsonWebKey GetServiceCertificateKey(byte[] receiptBytes) throw new InvalidOperationException("Issuer and service instance name are not matching."); } - // Check if we have cached keys for this domain - if (! _verificationKeysCache.TryGetValue(issuer, out JwksDocument jwksDocument)) + // Check if we have offline keys for this domain + if (! _offlineKeys.TryGetValue(issuer, out JwksDocument jwksDocument)) { // Get all the public keys from the JWKS endpoint jwksDocument = GetPublicKeys().Value; diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs index a674249b52f3..8382c72b2204 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs @@ -15,39 +15,39 @@ namespace Azure.Security.CodeTransparency /// /// A case-insensitive dictionary mapping ledger domains to their JWKS documents for offline verification. /// - public sealed class CodeTransparencyVerificationKeys + public sealed class CodeTransparencyOfflineKeys { - private IDictionary _serializedKeys; + private IDictionary _keysByDomain; /// - /// Initializes a new instance of CodeTransparencyVerificationKeys. + /// Initializes a new instance of CodeTransparencyOfflineKeys. /// - public CodeTransparencyVerificationKeys() + public CodeTransparencyOfflineKeys() { - _serializedKeys = new Dictionary(StringComparer.OrdinalIgnoreCase); + _keysByDomain = new Dictionary(StringComparer.OrdinalIgnoreCase); } /// /// Gets the dictionary of ledger domains to their JWKS documents. /// - public IDictionary SerializedKeys => _serializedKeys; + public IDictionary ByDomain => _keysByDomain; /// /// Adds or updates a JWKS document for the specified ledger domain. /// public void Add(string ledgerDomain, JwksDocument jwksDocument) { - _serializedKeys[ledgerDomain] = jwksDocument; + _keysByDomain[ledgerDomain] = jwksDocument; } - internal static CodeTransparencyVerificationKeys FromJsonDocument(JsonDocument jsonDocument) + internal static CodeTransparencyOfflineKeys FromJsonDocument(JsonDocument jsonDocument) { return DeserializeKeys(jsonDocument.RootElement); } - internal static CodeTransparencyVerificationKeys DeserializeKeys(JsonElement element, ModelReaderWriterOptions options = null) + internal static CodeTransparencyOfflineKeys DeserializeKeys(JsonElement element, ModelReaderWriterOptions options = null) { - var keys = new CodeTransparencyVerificationKeys(); + var keys = new CodeTransparencyOfflineKeys(); foreach (var property in element.EnumerateObject()) { diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs index 95fc8f581469..b309d6ad8266 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs @@ -77,6 +77,6 @@ public CodeTransparencyVerificationOptions() /// 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 CodeTransparencyVerificationKeys CodeTransparencyVerificationKeys { get; set; } = null; + public CodeTransparencyOfflineKeys CodeTransparencyOfflineKeys { get; set; } = null; } } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs index bde8f64955a8..11d6ba829327 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs @@ -490,7 +490,7 @@ public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + "}]}}"; var jsonDoc = JsonDocument.Parse(doc); - var offlineStore = CodeTransparencyVerificationKeys.FromJsonDocument(jsonDoc); + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); var options = new CodeTransparencyClientOptions { @@ -500,7 +500,7 @@ public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore var verificationOptions = new CodeTransparencyVerificationOptions { AuthorizedDomains = new string[] { "foo.bar.com" }, - CodeTransparencyVerificationKeys = offlineStore + CodeTransparencyOfflineKeys = offlineStore }; byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); From 7b7f2457c2771398ada9c9fd10664457725abdc5 Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 12:55:45 +0000 Subject: [PATCH 03/15] fix offline key verification, linting --- .../src/CodeTransparencyClient.cs | 7 +- ...Keys.cs => CodeTransparencyOfflineKeys.cs} | 11 +- .../CodeTransparencyVerificationOptions.cs | 1 + .../tests/CodeTransparencyClientUnitTests.cs | 110 ++++++++++-------- 4 files changed, 75 insertions(+), 54 deletions(-) rename sdk/confidentialledger/Azure.Security.CodeTransparency/src/{CodeTransparencyVerificationKeys.cs => CodeTransparencyOfflineKeys.cs} (80%) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index 46ae81cf6aee..79c53303b9ed 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Concurrent; +using System.Collections.ObjectModel; using System.Formats.Cbor; using System.IO; using System.Security.Cryptography.Cose; @@ -37,9 +38,9 @@ public partial class CodeTransparencyClient public static readonly string UnknownIssuerPrefix = "__unknown-issuer::"; /// - /// Public key storage used to verify receipts. It can be prepopulated to do offline verification. + /// Public key storage used to verify receipts. The value can be set through the verification options. /// - private IDictionary _offlineKeys = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private IReadOnlyDictionary _offlineKeys = null; /// /// Initializes a new instance of CodeTransparencyClient. The client will download its own @@ -522,7 +523,7 @@ private JsonWebKey GetServiceCertificateKey(byte[] receiptBytes) } // Check if we have offline keys for this domain - if (! _offlineKeys.TryGetValue(issuer, out JwksDocument jwksDocument)) + if (_offlineKeys == null || ! _offlineKeys.TryGetValue(issuer, out JwksDocument jwksDocument)) { // Get all the public keys from the JWKS endpoint jwksDocument = GetPublicKeys().Value; diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs similarity index 80% rename from sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs rename to sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs index 8382c72b2204..6131428cdc6e 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationKeys.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs @@ -4,6 +4,7 @@ using System; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; @@ -30,7 +31,7 @@ public CodeTransparencyOfflineKeys() /// /// Gets the dictionary of ledger domains to their JWKS documents. /// - public IDictionary ByDomain => _keysByDomain; + public IReadOnlyDictionary ByDomain => new ReadOnlyDictionary(_keysByDomain); /// /// Adds or updates a JWKS document for the specified ledger domain. @@ -40,6 +41,14 @@ public void Add(string ledgerDomain, JwksDocument jwksDocument) _keysByDomain[ledgerDomain] = jwksDocument; } + /// + /// Creates a CodeTransparencyOfflineKeys instance from a BinaryData containing JSON. + /// + public static CodeTransparencyOfflineKeys FromBinaryData(BinaryData json) + { + return FromJsonDocument(JsonDocument.Parse(json.ToString())); + } + internal static CodeTransparencyOfflineKeys FromJsonDocument(JsonDocument jsonDocument) { return DeserializeKeys(jsonDocument.RootElement); diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs index b309d6ad8266..49e7829cc8a6 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 . /// diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs index 11d6ba829327..3b046399261b 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs @@ -28,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(); @@ -61,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; } @@ -117,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); } @@ -133,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); @@ -163,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); @@ -198,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); @@ -232,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!"); @@ -291,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); @@ -348,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); @@ -368,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"); @@ -387,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"); @@ -405,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); @@ -424,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); @@ -446,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 { @@ -482,19 +494,15 @@ public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore Assert.Ignore("JsonWebKey to ECDsa is not supported on net462."); #else // Parse the JWKS JSON from the mocked response - string doc = "{\"foo.bar.com\":{\"keys\":" + - "[{\"crv\": \"P-384\"," + - "\"kid\":\"fb29ce6d6b37e7a0b03a5fc94205490e1c37de1f41f68b92e3620021e9981d01\"," + - "\"kty\":\"EC\"," + - "\"x\": \"Tv_tP9eJIb5oJY9YB6iAzMfds4v3N84f8pgcPYLaxd_Nj3Nb_dBm6Fc8ViDZQhGR\"," + - "\"y\": \"xJ7fI2kA8gs11XDc9h2zodU-fZYRrE0UJHpzPfDVJrOpTvPcDoC5EWOBx9Fks0bZ\"" + - "}]}}"; + string doc = "{\"foo.bar.com\":" + ValidSignedStatementJWKS + "}"; var jsonDoc = JsonDocument.Parse(doc); var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); + var mockTransport = new MockTransport(new MockResponse(503)); var options = new CodeTransparencyClientOptions { - IdentityClientEndpoint = "https://foo.bar.com" + IdentityClientEndpoint = "https://some.identity.com", + Transport = mockTransport, }; var verificationOptions = new CodeTransparencyVerificationOptions @@ -507,6 +515,8 @@ public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore // Should not make any network calls since we're using offline keys CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions, options); + + Assert.AreEqual(0, mockTransport.Requests.Count); #endif } @@ -521,7 +531,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 { @@ -545,7 +555,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 { From 407716f38414a29f79848ad4e3447bf49e6762f7 Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 15:55:06 +0000 Subject: [PATCH 04/15] add network behavior for offline keys --- .../src/CodeTransparencyClient.cs | 20 +++++- .../CodeTransparencyVerificationOptions.cs | 23 ++++++- .../tests/CodeTransparencyClientUnitTests.cs | 64 ++++++++++++++++++- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index 79c53303b9ed..838ddbd15177 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -42,6 +42,11 @@ public partial class CodeTransparencyClient /// 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. @@ -430,14 +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?.CodeTransparencyOfflineKeys != null) + if (verificationOptions?.OfflineKeys != null) { - clientInstance._offlineKeys = verificationOptions.CodeTransparencyOfflineKeys.ByDomain; + clientInstance._offlineKeys = verificationOptions.OfflineKeys.ByDomain; + clientInstance._offlineKeysAllowNetworkFallback = verificationOptions.OfflineKeysBehavior == OfflineKeysBehavior.FallbackToNetwork; } clientInstances[issuer] = clientInstance; } clientInstance.RunTransparentStatementVerification(transparentStatementCoseSign1Bytes, receiptBytes); + // If we reach here, verification succeeded if (isAuthorized) { validAuthorizedDomainsEncountered.Add(issuer); @@ -522,13 +529,20 @@ private JsonWebKey GetServiceCertificateKey(byte[] receiptBytes) throw new InvalidOperationException("Issuer and service instance name are not matching."); } + JwksDocument jwksDocument = null; // Check if we have offline keys for this domain - if (_offlineKeys == null || ! _offlineKeys.TryGetValue(issuer, out JwksDocument jwksDocument)) + 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/CodeTransparencyVerificationOptions.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs index 49e7829cc8a6..b18e4f666676 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs @@ -44,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 . /// @@ -78,6 +93,12 @@ public CodeTransparencyVerificationOptions() /// 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 CodeTransparencyOfflineKeys { get; set; } = null; + 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 3b046399261b..9d9e0c4766c5 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs @@ -488,7 +488,7 @@ public void VerifyTransparentStatement_success() } [Test] - public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore() + public void VerifyTransparentStatement_offline_success() { #if NET462 Assert.Ignore("JsonWebKey to ECDsa is not supported on net462."); @@ -508,7 +508,7 @@ public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore var verificationOptions = new CodeTransparencyVerificationOptions { AuthorizedDomains = new string[] { "foo.bar.com" }, - CodeTransparencyOfflineKeys = offlineStore + OfflineKeys = offlineStore }; byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); @@ -520,6 +520,66 @@ public void VerifyTransparentStatement_success_with_OfflineVerificationKeysStore #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 = "{}"; + 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"); + + // Should not make any network calls since we're using offline keys + 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 = "{}"; + 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() { From 11f9029e151aaf923770e09774688be458fc2161 Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 16:18:51 +0000 Subject: [PATCH 05/15] update public API --- .../api/Azure.Security.CodeTransparency.net8.0.cs | 14 ++++++++++++++ ...ure.Security.CodeTransparency.netstandard2.0.cs | 14 ++++++++++++++ 2 files changed, 28 insertions(+) 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..8d7934d14018 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,13 @@ public enum ServiceVersion V2025_01_31_Preview = 1, } } + public sealed partial class CodeTransparencyOfflineKeys + { + public CodeTransparencyOfflineKeys() { } + public System.Collections.Generic.IReadOnlyDictionary ByDomain { 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 enum CodeTransparencyOperationStatus { Running = 0, @@ -85,6 +92,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 +134,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..c3aa0111ff9e 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,13 @@ public enum ServiceVersion V2025_01_31_Preview = 1, } } + public sealed partial class CodeTransparencyOfflineKeys + { + public CodeTransparencyOfflineKeys() { } + public System.Collections.Generic.IReadOnlyDictionary ByDomain { 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 enum CodeTransparencyOperationStatus { Running = 0, @@ -85,6 +92,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 +134,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; } From 8b4e934703c420338066fb0473133a7867b1a44c Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 16:49:35 +0000 Subject: [PATCH 06/15] update changelog --- .../Azure.Security.CodeTransparency/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) 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 From 799318324f6a5cee57810130535e3601aef9ebdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20=28a=C9=AAv=C9=91r=29?= Date: Tue, 2 Dec 2025 16:56:24 +0000 Subject: [PATCH 07/15] use using when parsing JsonDocument Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/CodeTransparencyOfflineKeys.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs index 6131428cdc6e..70cf4ebf40b8 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs @@ -46,7 +46,10 @@ public void Add(string ledgerDomain, JwksDocument jwksDocument) /// public static CodeTransparencyOfflineKeys FromBinaryData(BinaryData json) { - return FromJsonDocument(JsonDocument.Parse(json.ToString())); + using (JsonDocument doc = JsonDocument.Parse(json.ToString())) + { + return FromJsonDocument(doc); + } } internal static CodeTransparencyOfflineKeys FromJsonDocument(JsonDocument jsonDocument) From af9a803fba7384198a33fe8fb08bc1e81260667e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivar=20=28a=C9=AAv=C9=91r=29?= Date: Tue, 2 Dec 2025 16:57:11 +0000 Subject: [PATCH 08/15] update test comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tests/CodeTransparencyClientUnitTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs index 9d9e0c4766c5..7b55d46805df 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs @@ -541,7 +541,7 @@ public void VerifyTransparentStatement_offline_success_with_fallback() byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); - // Should not make any network calls since we're using offline keys + // 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); From 7443b7d17dfb3396411a0444bd64f5ce1b28f1df Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 17:16:29 +0000 Subject: [PATCH 09/15] copilot suggestions --- .../src/CodeTransparencyClient.cs | 5 - .../src/CodeTransparencyOfflineKeys.cs | 5 +- .../tests/CodeTransparencyClientUnitTests.cs | 100 ++++++++++-------- 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index 838ddbd15177..b268a3382c38 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -3,15 +3,10 @@ using System; using System.Collections.Generic; -using System.Collections.Concurrent; -using System.Collections.ObjectModel; using System.Formats.Cbor; -using System.IO; using System.Security.Cryptography.Cose; using System.Security.Cryptography.X509Certificates; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Azure.Core; diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs index 70cf4ebf40b8..7712a89e3333 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs @@ -5,10 +5,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Text.Json; -using System.Text.Json.Serialization; using Azure.Core; namespace Azure.Security.CodeTransparency @@ -38,6 +35,8 @@ public CodeTransparencyOfflineKeys() /// public void Add(string ledgerDomain, JwksDocument jwksDocument) { + Argument.AssertNotNullOrEmpty(ledgerDomain, nameof(ledgerDomain)); + Argument.AssertNotNull(jwksDocument, nameof(jwksDocument)); _keysByDomain[ledgerDomain] = jwksDocument; } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs index 7b55d46805df..5fdf7c1e81b1 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyClientUnitTests.cs @@ -495,28 +495,30 @@ public void VerifyTransparentStatement_offline_success() #else // Parse the JWKS JSON from the mocked response string doc = "{\"foo.bar.com\":" + ValidSignedStatementJWKS + "}"; - var jsonDoc = JsonDocument.Parse(doc); - var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); - - var mockTransport = new MockTransport(new MockResponse(503)); - var options = new CodeTransparencyClientOptions + using (var jsonDoc = JsonDocument.Parse(doc)) { - IdentityClientEndpoint = "https://some.identity.com", - Transport = mockTransport, - }; + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); - var verificationOptions = new CodeTransparencyVerificationOptions - { - AuthorizedDomains = new string[] { "foo.bar.com" }, - OfflineKeys = offlineStore - }; + var mockTransport = new MockTransport(new MockResponse(503)); + var options = new CodeTransparencyClientOptions + { + IdentityClientEndpoint = "https://some.identity.com", + Transport = mockTransport, + }; - byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); + var verificationOptions = new CodeTransparencyVerificationOptions + { + AuthorizedDomains = new string[] { "foo.bar.com" }, + OfflineKeys = offlineStore + }; - // Should not make any network calls since we're using offline keys - CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions, options); + byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); - Assert.AreEqual(0, mockTransport.Requests.Count); + // Should not make any network calls since we're using offline keys + CodeTransparencyClient.VerifyTransparentStatement(transparentStatementBytes, verificationOptions, options); + + Assert.AreEqual(0, mockTransport.Requests.Count); + } #endif } @@ -528,23 +530,25 @@ public void VerifyTransparentStatement_offline_success_with_fallback() #else // Parse the JWKS JSON from the mocked response string doc = "{}"; - var jsonDoc = JsonDocument.Parse(doc); - var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); + using (var jsonDoc = JsonDocument.Parse(doc)) + { + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); - var (mockTransport, options) = createClientOptionsWithValidPublicKeyResponse(); + var (mockTransport, options) = createClientOptionsWithValidPublicKeyResponse(); - var verificationOptions = new CodeTransparencyVerificationOptions - { - AuthorizedDomains = new string[] { "foo.bar.com" }, - OfflineKeys = offlineStore - }; + var verificationOptions = new CodeTransparencyVerificationOptions + { + AuthorizedDomains = new string[] { "foo.bar.com" }, + OfflineKeys = offlineStore + }; - byte[] transparentStatementBytes = readFileBytes(name: "transparent_statement.cose"); + 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); + // 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); + Assert.AreEqual(1, mockTransport.Requests.Count); + } #endif } @@ -556,27 +560,29 @@ public void VerifyTransparentStatement_offline_failure_without_network_fallback( #else // Parse the JWKS JSON from the mocked response string doc = "{}"; - var jsonDoc = JsonDocument.Parse(doc); - var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); - - var mockTransport = new MockTransport(new MockResponse(503)); - var options = new CodeTransparencyClientOptions + using (var jsonDoc = JsonDocument.Parse(doc)) { - IdentityClientEndpoint = "https://some.identity.com", - Transport = mockTransport, - }; + var offlineStore = CodeTransparencyOfflineKeys.FromJsonDocument(jsonDoc); - var verificationOptions = new CodeTransparencyVerificationOptions - { - AuthorizedDomains = new string[] { "foo.bar.com" }, - OfflineKeys = offlineStore, - OfflineKeysBehavior = OfflineKeysBehavior.NoFallbackToNetwork - }; + var mockTransport = new MockTransport(new MockResponse(503)); + var options = new CodeTransparencyClientOptions + { + IdentityClientEndpoint = "https://some.identity.com", + Transport = mockTransport, + }; - 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); + 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 } From 333f69d71ec8879e877967b3aa807c793b08e5be Mon Sep 17 00:00:00 2001 From: Ivar Date: Tue, 2 Dec 2025 17:24:01 +0000 Subject: [PATCH 10/15] add test coverage for CodeTransparencyOfflineKeys --- .../tests/CodeTransparencyOfflineKeysTest.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs 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..c837a006eb4d --- /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.ByDomain); + Assert.AreEqual(0, keys.ByDomain.Count); + } + + [Test] + public void Add_AddsNewEntry() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc = new DummyJwksDocument("doc1"); + + keys.Add("ledger1", doc); + + Assert.AreEqual(1, keys.ByDomain.Count); + Assert.AreSame(doc, keys.ByDomain["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.ByDomain.Count); + Assert.AreSame(doc2, keys.ByDomain["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.ByDomain.Count); + Assert.AreSame(doc2, keys.ByDomain["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 ByDomain_ReturnsReadOnlyDictionary() + { + var keys = new CodeTransparencyOfflineKeys(); + var doc = new DummyJwksDocument("doc1"); + keys.Add("ledger1", doc); + + var byDomain = keys.ByDomain; + + // IReadOnlyDictionary is read-only by design + Assert.Throws(() => + { + var cast = (IDictionary)byDomain; + 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.ByDomain.Count); + Assert.IsTrue(keys.ByDomain.ContainsKey("ledger1.contoso.com")); + Assert.IsTrue(keys.ByDomain.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.ByDomain.Count); + Assert.IsTrue(keys.ByDomain.ContainsKey("ledger1.example.com")); + Assert.IsTrue(keys.ByDomain.ContainsKey("ledger2.example.com")); + } + } +} \ No newline at end of file From b9070bc72f48e12e0d249b1cd27b11297e002fe7 Mon Sep 17 00:00:00 2001 From: Ivar Date: Wed, 3 Dec 2025 13:40:27 +0000 Subject: [PATCH 11/15] rename ByDomain to ByIssuer to better reflect binding of the keys --- .../Azure.Security.CodeTransparency.net8.0.cs | 2 +- ...ecurity.CodeTransparency.netstandard2.0.cs | 2 +- .../src/CodeTransparencyClient.cs | 2 +- .../src/CodeTransparencyOfflineKeys.cs | 8 ++--- .../tests/CodeTransparencyOfflineKeysTest.cs | 34 +++++++++---------- 5 files changed, 24 insertions(+), 24 deletions(-) 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 8d7934d14018..7e9ee50c7e4b 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 @@ -77,7 +77,7 @@ public enum ServiceVersion public sealed partial class CodeTransparencyOfflineKeys { public CodeTransparencyOfflineKeys() { } - public System.Collections.Generic.IReadOnlyDictionary ByDomain { get { throw null; } } + 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; } } 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 c3aa0111ff9e..ce86c439d6c0 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 @@ -77,7 +77,7 @@ public enum ServiceVersion public sealed partial class CodeTransparencyOfflineKeys { public CodeTransparencyOfflineKeys() { } - public System.Collections.Generic.IReadOnlyDictionary ByDomain { get { throw null; } } + 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; } } diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index b268a3382c38..4daca2521cfd 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -432,7 +432,7 @@ public static void VerifyTransparentStatement(byte[] transparentStatementCoseSig clientInstance = new CodeTransparencyClient(new Uri($"https://{issuer}"), clientOptions); if (verificationOptions?.OfflineKeys != null) { - clientInstance._offlineKeys = verificationOptions.OfflineKeys.ByDomain; + clientInstance._offlineKeys = verificationOptions.OfflineKeys.ByIssuer; clientInstance._offlineKeysAllowNetworkFallback = verificationOptions.OfflineKeysBehavior == OfflineKeysBehavior.FallbackToNetwork; } clientInstances[issuer] = clientInstance; diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs index 7712a89e3333..bcaa79a9f5a9 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs @@ -15,20 +15,20 @@ namespace Azure.Security.CodeTransparency /// public sealed class CodeTransparencyOfflineKeys { - private IDictionary _keysByDomain; + private IDictionary _keysByIssuer; /// /// Initializes a new instance of CodeTransparencyOfflineKeys. /// public CodeTransparencyOfflineKeys() { - _keysByDomain = new Dictionary(StringComparer.OrdinalIgnoreCase); + _keysByIssuer = new Dictionary(StringComparer.OrdinalIgnoreCase); } /// /// Gets the dictionary of ledger domains to their JWKS documents. /// - public IReadOnlyDictionary ByDomain => new ReadOnlyDictionary(_keysByDomain); + public IReadOnlyDictionary ByIssuer => new ReadOnlyDictionary(_keysByIssuer); /// /// Adds or updates a JWKS document for the specified ledger domain. @@ -37,7 +37,7 @@ public void Add(string ledgerDomain, JwksDocument jwksDocument) { Argument.AssertNotNullOrEmpty(ledgerDomain, nameof(ledgerDomain)); Argument.AssertNotNull(jwksDocument, nameof(jwksDocument)); - _keysByDomain[ledgerDomain] = jwksDocument; + _keysByIssuer[ledgerDomain] = jwksDocument; } /// diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs index c837a006eb4d..7e9f3be2e215 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/tests/CodeTransparencyOfflineKeysTest.cs @@ -30,8 +30,8 @@ public void Constructor_InitializesEmptyDictionary() { var keys = new CodeTransparencyOfflineKeys(); - Assert.IsNotNull(keys.ByDomain); - Assert.AreEqual(0, keys.ByDomain.Count); + Assert.IsNotNull(keys.ByIssuer); + Assert.AreEqual(0, keys.ByIssuer.Count); } [Test] @@ -42,8 +42,8 @@ public void Add_AddsNewEntry() keys.Add("ledger1", doc); - Assert.AreEqual(1, keys.ByDomain.Count); - Assert.AreSame(doc, keys.ByDomain["ledger1"]); + Assert.AreEqual(1, keys.ByIssuer.Count); + Assert.AreSame(doc, keys.ByIssuer["ledger1"]); } [Test] @@ -56,8 +56,8 @@ public void Add_UpdatesExistingEntry() keys.Add("ledger1", doc1); keys.Add("ledger1", doc2); - Assert.AreEqual(1, keys.ByDomain.Count); - Assert.AreSame(doc2, keys.ByDomain["ledger1"]); + Assert.AreEqual(1, keys.ByIssuer.Count); + Assert.AreSame(doc2, keys.ByIssuer["ledger1"]); } [Test] @@ -70,8 +70,8 @@ public void Add_IsCaseInsensitiveOnDomain() keys.Add("Ledger.Domain", doc1); keys.Add("ledger.domain", doc2); - Assert.AreEqual(1, keys.ByDomain.Count); - Assert.AreSame(doc2, keys.ByDomain["LEDGER.DOMAIN"]); + Assert.AreEqual(1, keys.ByIssuer.Count); + Assert.AreSame(doc2, keys.ByIssuer["LEDGER.DOMAIN"]); } [Test] @@ -101,18 +101,18 @@ public void Add_ThrowsOnNullJwksDocument() } [Test] - public void ByDomain_ReturnsReadOnlyDictionary() + public void ByIssuer_ReturnsReadOnlyDictionary() { var keys = new CodeTransparencyOfflineKeys(); var doc = new DummyJwksDocument("doc1"); keys.Add("ledger1", doc); - var byDomain = keys.ByDomain; + var byIssuer = keys.ByIssuer; // IReadOnlyDictionary is read-only by design Assert.Throws(() => { - var cast = (IDictionary)byDomain; + var cast = (IDictionary)byIssuer; cast["ledger2"] = doc; }); } @@ -133,9 +133,9 @@ public void FromBinaryData_ParsesMultipleEntries() var keys = CodeTransparencyOfflineKeys.FromBinaryData(binary); - Assert.AreEqual(2, keys.ByDomain.Count); - Assert.IsTrue(keys.ByDomain.ContainsKey("ledger1.contoso.com")); - Assert.IsTrue(keys.ByDomain.ContainsKey("ledger2.contoso.com")); + Assert.AreEqual(2, keys.ByIssuer.Count); + Assert.IsTrue(keys.ByIssuer.ContainsKey("ledger1.contoso.com")); + Assert.IsTrue(keys.ByIssuer.ContainsKey("ledger2.contoso.com")); } [Test] @@ -150,9 +150,9 @@ public void DeserializeKeys_UsesPropertyNamesAsLedgerDomains() using var doc = JsonDocument.Parse(json); var keys = CodeTransparencyOfflineKeys.DeserializeKeys(doc.RootElement); - Assert.AreEqual(2, keys.ByDomain.Count); - Assert.IsTrue(keys.ByDomain.ContainsKey("ledger1.example.com")); - Assert.IsTrue(keys.ByDomain.ContainsKey("ledger2.example.com")); + 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 From d9f59a35b339a4c590521c767e11905c3dbdce9f Mon Sep 17 00:00:00 2001 From: Ivar Date: Wed, 3 Dec 2025 14:56:44 +0000 Subject: [PATCH 12/15] fix verification logic when no authorized keys are provided like in an offline case --- .../src/CodeTransparencyClient.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs index 4daca2521cfd..5482adc791ee 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyClient.cs @@ -355,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); @@ -370,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); @@ -462,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.")); } @@ -474,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.")); } From bffa39afba12507fded456c33f7a55ae483b3763 Mon Sep 17 00:00:00 2001 From: Ivar Date: Wed, 3 Dec 2025 14:57:09 +0000 Subject: [PATCH 13/15] comment fix --- .../src/CodeTransparencyVerificationOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs index b18e4f666676..e08aa79c9222 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyVerificationOptions.cs @@ -79,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; From 12d77962060bfc223fc518de146c9485cb141870 Mon Sep 17 00:00:00 2001 From: Ivar Date: Thu, 4 Dec 2025 11:14:40 +0000 Subject: [PATCH 14/15] adds sample and test to do offline verification --- .../Sample2_ReceiptDownloadVerification.md | 43 ++++++++++ .../src/CodeTransparencyOfflineKeys.cs | 20 +++++ .../tests/SamplesUnitTests.cs | 84 +++++++++++++++++++ 3 files changed, 147 insertions(+) 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/CodeTransparencyOfflineKeys.cs b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs index bcaa79a9f5a9..840234efdb51 100644 --- a/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs +++ b/sdk/confidentialledger/Azure.Security.CodeTransparency/src/CodeTransparencyOfflineKeys.cs @@ -51,6 +51,26 @@ public static CodeTransparencyOfflineKeys FromBinaryData(BinaryData json) } } + /// + /// 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); 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() { From 36f1614afe318958fa1b8c895abc541755f515c3 Mon Sep 17 00:00:00 2001 From: Ivar Date: Thu, 4 Dec 2025 11:45:01 +0000 Subject: [PATCH 15/15] regenerate external api --- .../api/Azure.Security.CodeTransparency.net8.0.cs | 1 + .../api/Azure.Security.CodeTransparency.netstandard2.0.cs | 1 + 2 files changed, 2 insertions(+) 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 7e9ee50c7e4b..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 @@ -80,6 +80,7 @@ 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 { 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 ce86c439d6c0..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 @@ -80,6 +80,7 @@ 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 {