From 8f401bbf505be3ab31e6fb6f23228628eab82e1d Mon Sep 17 00:00:00 2001 From: Abhijeet Baranwal Date: Thu, 30 Jul 2026 16:21:29 +0530 Subject: [PATCH 1/3] Harden sample authentication and storage access --- Scripts/PythonScript/example_ase.py | 17 +++++++++++------ .../CreateVmSample/CreateVmSample/App.config | 2 +- .../AseServiceClientCredentials.cs | 11 ----------- .../CreateVmSample/CreateVmSample.csproj | 4 ++-- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/Scripts/PythonScript/example_ase.py b/Scripts/PythonScript/example_ase.py index 020ef49..76d0204 100644 --- a/Scripts/PythonScript/example_ase.py +++ b/Scripts/PythonScript/example_ase.py @@ -18,6 +18,7 @@ import traceback import uuid import sys +from datetime import datetime, timedelta from azure.common.client_factory import get_client_from_cli_profile from azure.common.credentials import ServicePrincipalCredentials @@ -181,12 +182,11 @@ def run_example(): blob_endpoint=storage_endpoint_suffix, ) - # Create a container called 'vmimages'. - # Set the permission so the blobs are public. - # Upload the created file, use vhd_file_name for the blob name. + # Create a private container and upload the VHD. + # Generate a short-lived SAS URL for image creation instead of exposing the blob publicly. print("\nUploading to Azure Stack Storage as blob:\n\t" + vhd_file_name) blob_client = PageBlobService(connection_string=connection_string) - container_client = blob_client.create_container(container_name, public_access='container', fail_on_exist=False) + container_client = blob_client.create_container(container_name, fail_on_exist=False) blob_client.create_blob_from_path(container_name, vhd_file_name, vhd_file_path) # List the blobs in the container @@ -195,8 +195,13 @@ def run_example(): for blob in blob_list: print("\t" + blob.name) - # Construct the blob uri so it can be used to create the VM image - blob_uri = urlparse(arm_url).scheme + '://' + blob_client.primary_endpoint + '/' + container_name + '/' + vhd_file_name + # Construct a short-lived SAS URI so it can be used to create the VM image. + sas_token = blob_client.generate_blob_shared_access_signature( + container_name, + vhd_file_name, + permission='r', + expiry=datetime.utcnow() + timedelta(hours=1)) + blob_uri = urlparse(arm_url).scheme + '://' + blob_client.primary_endpoint + '/' + container_name + '/' + vhd_file_name + '?' + sas_token # Create image from the VHD async_creation = compute_client.images.create_or_update( diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/App.config b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/App.config index fa062c2..b38a435 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/App.config +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/App.config @@ -7,7 +7,7 @@ - + diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs index 7e87f3e..e2892bc 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs @@ -4,10 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Net.Security; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -73,15 +71,6 @@ public string AudienceResourceUrl protected set; } - static AseServiceClientCredentials() - { - ServicePointManager.ServerCertificateValidationCallback = new - RemoteCertificateValidationCallback - ( - delegate { return true; } - ); - } - public AseServiceClientCredentials(string clientId, string clientSecret, string tenantId, diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/CreateVmSample.csproj b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/CreateVmSample.csproj index f886381..ad2da5c 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/CreateVmSample.csproj +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/CreateVmSample.csproj @@ -52,8 +52,8 @@ ..\packages\Microsoft.Rest.ClientRuntime.Azure.3.3.19\lib\net461\Microsoft.Rest.ClientRuntime.Azure.dll - - ..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + + ..\packages\Newtonsoft.Json.13.0.1\lib\net45\Newtonsoft.Json.dll From 413e9a94d4bfdc31e75231a686f9bab4050a55a8 Mon Sep 17 00:00:00 2001 From: Abhijeet Baranwal Date: Fri, 7 Aug 2026 12:51:27 +0530 Subject: [PATCH 2/3] Add opt-in certificate pinning with unit tests Replace removed TLS bypass with an opt-in thumbprint-pinned validation for the ASE login/management hosts, harden request-host extraction (HttpWebRequest/HttpRequestMessage/string), and add an MSTest project covering the pinning decisions. --- .../CertificatePinningTests.cs | 228 ++++++++++++++++++ .../CreateVmSample.Tests.csproj | 80 ++++++ .../Properties/AssemblyInfo.cs | 19 ++ .../CreateVmSample.Tests/packages.config | 5 + .../CreateVmSample/CreateVmSample.sln | 6 + .../AseServiceClientCredentials.cs | 112 ++++++++- .../CreateVmSample/CreateVmSample/Program.cs | 9 +- .../CreateVmSample/Properties/AssemblyInfo.cs | 3 + 8 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CertificatePinningTests.cs create mode 100644 dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CreateVmSample.Tests.csproj create mode 100644 dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/Properties/AssemblyInfo.cs create mode 100644 dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/packages.config diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CertificatePinningTests.cs b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CertificatePinningTests.cs new file mode 100644 index 0000000..9a61535 --- /dev/null +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CertificatePinningTests.cs @@ -0,0 +1,228 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using CreateVmSample; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace CreateVmSample.Tests +{ + [TestClass] + public class CertificatePinningTests + { + private const string ManagementUrl = "https://management.dbe-appliance.microsoftdatabox.com/"; + private const string AuthorityUrl = "https://login.dbe-appliance.microsoftdatabox.com/adfs/"; + private const string ManagementHost = "management.dbe-appliance.microsoftdatabox.com"; + + [TestInitialize] + public void TestInitialize() + { + AseServiceClientCredentials.ResetCertificateValidationForTest(); + } + + [TestCleanup] + public void TestCleanup() + { + AseServiceClientCredentials.ResetCertificateValidationForTest(); + } + + private static X509Certificate2 CreateSelfSignedCertificate(string subjectName = "CN=ase-pinning-test") + { + using (var rsa = RSA.Create(2048)) + { + var request = new CertificateRequest( + subjectName, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1)); + } + } + + [TestMethod] + public void NormalizeThumbprint_StripsSeparatorsAndUppercases() + { + var normalizedWithSeparators = AseServiceClientCredentials.NormalizeThumbprint("ab:cd ef"); + var normalizedPlain = AseServiceClientCredentials.NormalizeThumbprint("ABCDEF"); + + Assert.AreEqual("ABCDEF", normalizedWithSeparators); + Assert.AreEqual(normalizedPlain, normalizedWithSeparators); + } + + [TestMethod] + public void NormalizeThumbprint_NullOrWhitespace_ReturnsNull() + { + Assert.IsNull(AseServiceClientCredentials.NormalizeThumbprint(null)); + Assert.IsNull(AseServiceClientCredentials.NormalizeThumbprint(string.Empty)); + Assert.IsNull(AseServiceClientCredentials.NormalizeThumbprint(" ")); + } + + [TestMethod] + public void Validate_NoSslErrors_ReturnsTrue() + { + using (var cert = CreateSelfSignedCertificate()) + { + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + ManagementHost, + cert, + null, + SslPolicyErrors.None); + + Assert.IsTrue(result); + } + } + + [TestMethod] + public void Validate_MatchingThumbprint_AllowedHost_ChainError_ReturnsTrue() + { + using (var cert = CreateSelfSignedCertificate()) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + cert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + ManagementHost, + cert, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsTrue(result); + } + } + + [TestMethod] + public void Validate_MatchingThumbprint_DisallowedHost_ReturnsFalse() + { + using (var cert = CreateSelfSignedCertificate()) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + cert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + "evil.contoso.com", + cert, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsFalse(result); + } + } + + [TestMethod] + public void Validate_NonMatchingThumbprint_AllowedHost_ReturnsFalse() + { + using (var configuredCert = CreateSelfSignedCertificate("CN=ase-pinning-configured")) + using (var presentedCert = CreateSelfSignedCertificate("CN=ase-pinning-presented")) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + configuredCert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + ManagementHost, + presentedCert, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsFalse(result); + } + } + + [TestMethod] + public void Validate_NullCertificate_ReturnsFalse() + { + using (var cert = CreateSelfSignedCertificate()) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + cert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + ManagementHost, + null, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsFalse(result); + } + } + + [TestMethod] + public void Validate_HttpWebRequestSender_HostExtracted_ReturnsTrue() + { + using (var cert = CreateSelfSignedCertificate()) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + cert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + var sender = (HttpWebRequest)WebRequest.Create(ManagementUrl); + + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + sender, + cert, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsTrue(result); + } + } + + [TestMethod] + public void Validate_HttpRequestMessageSender_HostExtracted_ReturnsTrue() + { + using (var cert = CreateSelfSignedCertificate()) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + cert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + using (var sender = new HttpRequestMessage(HttpMethod.Get, ManagementUrl)) + { + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + sender, + cert, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsTrue(result); + } + } + } + + [TestMethod] + public void Validate_UnknownSenderType_ReturnsFalse() + { + using (var cert = CreateSelfSignedCertificate()) + { + AseServiceClientCredentials.ConfigureCertificateValidationForTest( + cert.GetCertHashString(), + ManagementUrl, + AuthorityUrl); + + var result = AseServiceClientCredentials.ValidatePinnedCertificate( + new object(), + cert, + null, + SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.IsFalse(result); + } + } + } +} diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CreateVmSample.Tests.csproj b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CreateVmSample.Tests.csproj new file mode 100644 index 0000000..757a6e4 --- /dev/null +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/CreateVmSample.Tests.csproj @@ -0,0 +1,80 @@ + + + + + + Debug + AnyCPU + {7F3C9A21-4E2B-4C7D-9F1A-2B6E5D8C0A11} + Library + CreateVmSample.Tests + CreateVmSample.Tests + v4.7.2 + 512 + true + true + + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\MSTest.TestFramework.2.2.10\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll + + + ..\packages\MSTest.TestFramework.2.2.10\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + + + ..\packages\Microsoft.Rest.ClientRuntime.2.3.19\lib\net461\Microsoft.Rest.ClientRuntime.dll + + + + + + + + + + + + + + + + + + + + + + {95737F72-6C25-4AE3-98D2-FC0258A0F612} + CreateVmSample + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/Properties/AssemblyInfo.cs b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..bfc9e25 --- /dev/null +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("CreateVmSample.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("CreateVmSample.Tests")] +[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: Guid("7f3c9a21-4e2b-4c7d-9f1a-2b6e5d8c0a11")] + +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/packages.config b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/packages.config new file mode 100644 index 0000000..8908f05 --- /dev/null +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.Tests/packages.config @@ -0,0 +1,5 @@ + + + + + diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.sln b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.sln index 998e209..f244fba 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.sln +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 16.0.31105.61 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreateVmSample", "CreateVmSample\CreateVmSample.csproj", "{95737F72-6C25-4AE3-98D2-FC0258A0F612}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreateVmSample.Tests", "CreateVmSample.Tests\CreateVmSample.Tests.csproj", "{7F3C9A21-4E2B-4C7D-9F1A-2B6E5D8C0A11}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {95737F72-6C25-4AE3-98D2-FC0258A0F612}.Debug|Any CPU.Build.0 = Debug|Any CPU {95737F72-6C25-4AE3-98D2-FC0258A0F612}.Release|Any CPU.ActiveCfg = Release|Any CPU {95737F72-6C25-4AE3-98D2-FC0258A0F612}.Release|Any CPU.Build.0 = Release|Any CPU + {7F3C9A21-4E2B-4C7D-9F1A-2B6E5D8C0A11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F3C9A21-4E2B-4C7D-9F1A-2B6E5D8C0A11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F3C9A21-4E2B-4C7D-9F1A-2B6E5D8C0A11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F3C9A21-4E2B-4C7D-9F1A-2B6E5D8C0A11}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs index e2892bc..75c0798 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/AseServiceClientCredentials.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -15,6 +18,9 @@ namespace CreateVmSample public class AseServiceClientCredentials : ServiceClientCredentials { private const string PowerShellAppId = "1950a258-227b-4e31-a9cf-717495945fc2"; + private static readonly object CertificateValidationLock = new object(); + private static readonly HashSet AllowedCertificateHosts = new HashSet(StringComparer.OrdinalIgnoreCase); + private static string ExpectedCertificateThumbprint; /// /// login client id @@ -75,13 +81,15 @@ public AseServiceClientCredentials(string clientId, string clientSecret, string tenantId, string audianceResourceUrl, - string authenticationAuthority) + string authenticationAuthority, + string expectedCertificateThumbprint) { this.ClientId = clientId; this.ClientSecretKey = clientSecret; this.TenantId = tenantId; this.AudienceResourceUrl = audianceResourceUrl; this.AuthenticationAuthority = authenticationAuthority; + ConfigureCertificateValidation(expectedCertificateThumbprint, audianceResourceUrl, authenticationAuthority); } public AseServiceClientCredentials(string clientId, @@ -93,12 +101,112 @@ public AseServiceClientCredentials(string clientId, clientSecret, tenantId, $"https://management.dbe-{edgeApplianceHostName.ToLower()}.microsoftdatabox.com/", - $"https://login.dbe-{edgeApplianceHostName.ToLower()}.microsoftdatabox.com/adfs/" + $"https://login.dbe-{edgeApplianceHostName.ToLower()}.microsoftdatabox.com/adfs/", + null ) { } + public AseServiceClientCredentials(string clientId, + string clientSecret, + string tenantId, + string edgeApplianceHostName, + string expectedCertificateThumbprint) + : this( + clientId, + clientSecret, + tenantId, + $"https://management.dbe-{edgeApplianceHostName.ToLower()}.microsoftdatabox.com/", + $"https://login.dbe-{edgeApplianceHostName.ToLower()}.microsoftdatabox.com/adfs/", + expectedCertificateThumbprint + ) + { + + } + + internal static void ConfigureCertificateValidationForTest(string expectedCertificateThumbprint, string audienceResourceUrl, string authenticationAuthority) + { + ConfigureCertificateValidation(expectedCertificateThumbprint, audienceResourceUrl, authenticationAuthority); + } + + internal static void ResetCertificateValidationForTest() + { + lock (CertificateValidationLock) + { + AllowedCertificateHosts.Clear(); + ExpectedCertificateThumbprint = null; + ServicePointManager.ServerCertificateValidationCallback = null; + } + } + + private static void ConfigureCertificateValidation(string expectedCertificateThumbprint, string audienceResourceUrl, string authenticationAuthority) + { + var normalizedThumbprint = NormalizeThumbprint(expectedCertificateThumbprint); + if (string.IsNullOrEmpty(normalizedThumbprint)) + { + return; + } + + lock (CertificateValidationLock) + { + ExpectedCertificateThumbprint = normalizedThumbprint; + AllowedCertificateHosts.Clear(); + AllowedCertificateHosts.Add(new Uri(audienceResourceUrl).Host); + AllowedCertificateHosts.Add(new Uri(authenticationAuthority).Host); + ServicePointManager.ServerCertificateValidationCallback = ValidatePinnedCertificate; + } + } + + internal static bool ValidatePinnedCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + { + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + + if (certificate == null || string.IsNullOrEmpty(ExpectedCertificateThumbprint)) + { + return false; + } + + var requestHost = ExtractRequestHost(sender); + if (string.IsNullOrEmpty(requestHost) || !AllowedCertificateHosts.Contains(requestHost)) + { + return false; + } + + return string.Equals( + NormalizeThumbprint(certificate.GetCertHashString()), + ExpectedCertificateThumbprint, + StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractRequestHost(object sender) + { + switch (sender) + { + case HttpWebRequest webRequest: + return webRequest.RequestUri?.Host; + case HttpRequestMessage requestMessage: + return requestMessage.RequestUri?.Host; + case string host: + return host; + default: + return null; + } + } + + internal static string NormalizeThumbprint(string thumbprint) + { + if (string.IsNullOrWhiteSpace(thumbprint)) + { + return null; + } + + return new string(thumbprint.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); + } + public override void InitializeServiceClient(ServiceClient client) { var authContext = new AuthenticationContext($"{this.AuthenticationAuthority}", false); diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Program.cs b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Program.cs index abad8b5..3a17d7c 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Program.cs +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Program.cs @@ -52,6 +52,11 @@ static void Main(string[] args) // you can get this value from the DBE local use var hostName = ""; + // Optional appliance certificate thumbprint. + // Leave this blank to use normal TLS validation. + // Set it only when you need to trust a specific ASE certificate for the management and login endpoints. + var applianceCertificateThumbprint = ""; + // Local Arm Authority URL // This is the authority/authenticating website URL // You can also find this value from the local UI @@ -115,8 +120,8 @@ static void Main(string[] args) // construct a credentials object. // If you have changed the domain name of the device you below constructor to consruct the credentials object - // var clientCreds = new AseServiceClientCredentials(userName, password, tenantId, managementEndpointUrl, authenticationAuthorityUrl); - var clientCreds = new AseServiceClientCredentials(userName, password, tenantId, hostName); + // var clientCreds = new AseServiceClientCredentials(userName, password, tenantId, managementEndpointUrl, authenticationAuthorityUrl, applianceCertificateThumbprint); + var clientCreds = new AseServiceClientCredentials(userName, password, tenantId, hostName, applianceCertificateThumbprint); // Once you have created a credentials obejct you can now interact with any Azure SDK component and operate with the device diff --git a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Properties/AssemblyInfo.cs b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Properties/AssemblyInfo.cs index 9a52b91..5fe9b3e 100644 --- a/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Properties/AssemblyInfo.cs +++ b/dotnetSamples/LocalArm/CreateVmSample/CreateVmSample/Properties/AssemblyInfo.cs @@ -2,6 +2,9 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +// Allow the test project to exercise internal certificate-pinning members. +[assembly: InternalsVisibleTo("CreateVmSample.Tests")] + // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information // associated with an assembly. From f95e8aba704ccf6cffae8bfb8f18943f6bc69911 Mon Sep 17 00:00:00 2001 From: Abhijeet Baranwal Date: Fri, 7 Aug 2026 12:55:35 +0530 Subject: [PATCH 3/3] Add beginner-friendly ASE sample explainer doc Explains what the LocalArm and Python samples do, the certificate-pinning behavior, and the storage SAS change, with diagrams. --- docs/ase-sample-explained.md | 214 +++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/ase-sample-explained.md diff --git a/docs/ase-sample-explained.md b/docs/ase-sample-explained.md new file mode 100644 index 0000000..66c1726 --- /dev/null +++ b/docs/ase-sample-explained.md @@ -0,0 +1,214 @@ +# Azure Stack Edge (ASE) VM Sample — Explained Simply + +New to Azure Stack Edge? Start here. This doc explains, in plain language, what the +affected sample does, why it exists, and what the recent security fixes changed. + +--- + +## 1. What is Azure Stack Edge, in one picture? + +Azure Stack Edge (ASE) is a **physical appliance** Microsoft ships to your location +(a factory, a store, a ship, a remote site). It runs a small slice of Azure **locally**, +so you can run virtual machines (VMs) and containers close to your data — even with poor +or no internet. + +Think of it as **"a tiny piece of Azure that lives in your building."** + +```mermaid +flowchart LR + subgraph Cloud["☁️ Azure Cloud (far away)"] + Portal["Azure Portal / Cloud ARM"] + end + subgraph Edge["🏭 Your Site (local)"] + ASE["Azure Stack Edge device
(runs VMs locally)"] + VM["Your Virtual Machine"] + end + Portal -. "register / manage" .-> ASE + ASE --> VM +``` + +Key idea: the device has its **own local ARM** (Azure Resource Manager). ARM is the +"control panel API" of Azure. Normally ARM lives in the cloud; on ASE, a copy runs +**on the box itself** so you can create resources locally. + +--- + +## 2. What does this sample actually do? + +The sample is a small program that **creates a virtual machine on the ASE device**. + +It does not touch the public cloud to create the VM — it talks to the **local ARM +endpoint on the appliance**. The sample exists to show developers the exact steps and +API calls needed to automate VM creation on ASE. + +There are several flavors of the same idea in this repo: + +| Flavor | Folder | Language | +|--------|--------|----------| +| Local ARM (the one these fixes touch) | `dotnetSamples/LocalArm/CreateVmSample` | C# / .NET | +| Python script | `Scripts/PythonScript/example_ase.py` | Python | +| PowerShell | `Scripts/AzPowerShellScript` | PowerShell | + +This doc focuses on the **C# LocalArm sample** and the **Python sample**, because those +are the two the security fixes changed. + +--- + +## 3. The steps the sample performs + +At a high level, creating a VM on ASE looks like this: + +```mermaid +flowchart TD + A["1. Sign in to the device
(get an access token)"] --> B["2. Create a resource group"] + B --> C["3. Upload a VHD
(the VM's disk image)"] + C --> D["4. Create an Image from the VHD"] + D --> E["5. Create a network card (NIC)"] + E --> F["6. Create the Virtual Machine"] + F --> G["✅ VM running on the ASE device"] +``` + +A **VHD** is just a file that contains a ready-made operating system disk (for example, +an Ubuntu or Windows disk). You upload it to the device's local storage, turn it into an +**image**, and then create VMs from that image. + +--- + +## 4. How sign-in works (and where the certificate fits) + +To talk to the device, the sample must first **prove who it is** and get a token. It +contacts two HTTPS endpoints **on the appliance**: + +- the **login endpoint** (ADFS): `https://login.dbe-.microsoftdatabox.com/adfs/` +- the **management endpoint** (local ARM): `https://management.dbe-.microsoftdatabox.com/` + +```mermaid +sequenceDiagram + participant App as Sample program + participant Login as ASE login endpoint (HTTPS) + participant ARM as ASE management endpoint (HTTPS) + + App->>Login: username + password + Login-->>App: access token 🎟️ + App->>ARM: "create VM" + token + ARM-->>App: VM created ✅ +``` + +Because these calls are **HTTPS**, the device presents a **TLS certificate** — a digital +ID card that proves "you are really talking to the appliance and not an imposter." Your +computer normally checks that ID card automatically. + +--- + +## 5. The security problem that was fixed (certificate change) + +### What the old code did + +The original sample **turned off certificate checking completely** — for the whole +program. It effectively said: *"accept any HTTPS certificate, always, no questions asked."* + +```mermaid +flowchart LR + App["Sample program"] -->|"accepts ANY certificate 😬"| Anything["Any server
(real device OR imposter)"] +``` + +That made setup easy, but it is dangerous: if an attacker sat between you and the device, +your program would happily send your **username, password, and access token** to the +attacker, because it never verified who it was talking to. This is called a +**man-in-the-middle (MITM)** attack. + +### What the fix does + +The blanket "accept everything" switch is **removed**. Now: + +```mermaid +flowchart TD + Start["Sample makes HTTPS call"] --> Q{"Is the certificate
valid & trusted?"} + Q -->|Yes| OK["✅ Connect"] + Q -->|No| Q2{"Did you provide a
specific thumbprint
to trust?"} + Q2 -->|"No thumbprint"| Fail["❌ Connection fails (safe default)"] + Q2 -->|"Thumbprint set"| Q3{"Does the device's
certificate match that
exact thumbprint
AND host?"} + Q3 -->|Yes| OK + Q3 -->|No| Fail +``` + +Two ways to connect now, both safe: + +1. **Normal (recommended):** trust the appliance certificate properly on your machine + (install the device/root certificate), then the sample "just works" with full validation. +2. **Pinned exception (opt-in):** if you must trust a specific device certificate (common + in labs or private setups), you provide its **thumbprint** — a unique fingerprint of + that one certificate. The sample will then trust **only** that exact certificate, and + **only** for the ASE login/management hosts. Everything else is still rejected. + +A **thumbprint** is like the certificate's unique serial number. Trusting one thumbprint +is very different from "trust everything" — it is a single, specific ID you have chosen in +advance. + +### How you use the opt-in in the sample + +In `Program.cs` there is now a clearly labeled field: + +```csharp +// Optional appliance certificate thumbprint. +// Leave this blank to use normal TLS validation. +// Set it only when you need to trust a specific ASE certificate. +var applianceCertificateThumbprint = ""; +``` + +- Leave it **empty** → normal, full certificate validation. +- Paste the device's certificate **thumbprint** → trust only that certificate for the ASE + endpoints. + +You can read the thumbprint from the ASE local UI, or with: +`openssl s_client -connect management.dbe-.microsoftdatabox.com:443` + +--- + +## 6. The second fix (Python storage change) + +The Python sample uploads the VHD to a storage container on the device. The old code made +that container **public** — anyone who could reach it could read the VM disk image. + +```mermaid +flowchart LR + subgraph Before["❌ Before"] + B1["VHD in PUBLIC container"] -->|"anyone can read"| B2["🌐 Any anonymous user"] + end + subgraph After["✅ After"] + A1["VHD in PRIVATE container"] -->|"short-lived read link (SAS)"| A2["Only the image-create step"] + end +``` + +The fix keeps the container **private** and instead generates a **SAS** (Shared Access +Signature) — a temporary, read-only link that expires after one hour and is only used to +create the image. No more public exposure of your disk image. + +--- + +## 7. How this is tested + +The certificate-pinning logic now has **automated unit tests** (MSTest) that verify: + +- with no errors, a valid certificate is accepted; +- a matching thumbprint on an allowed ASE host is accepted, even with a chain warning; +- a **wrong** thumbprint, a **wrong** host, a **missing** certificate, or an **unknown + caller type** are all rejected. + +This proves the safe behavior holds without needing a physical device. + +```mermaid +flowchart LR + T["Unit tests"] --> V["ValidatePinnedCertificate()"] + V --> Pass["✅ correct cert + host"] + V --> Reject["❌ wrong cert / wrong host / no cert"] +``` + +--- + +## 8. Summary in one line + +The sample **creates a VM on your local Azure Stack Edge box**; the fixes make it **verify +who it is talking to** (instead of trusting any certificate) and **stop exposing the VM +disk image publicly** — while still giving you a safe, opt-in way to trust a specific +device certificate.