0

I want to be able to generate CA root and server certificates using .NET. The end goal is to use these to secure inter-service gRPC comms, but right now everything is running locally.

When I generate a pfx file for the server certificate I manually import it into the Local Machine Personal certificate store marking the private key as exportable, import the root cert into the Trusted Root store and check that everything is working locally with IIS and Chrome.

When I do this using OpenSSL, everything works as expected including a local .NET gRPC service.

When I do this using .NET, IIS works fine, but the gRPC service does not. I think this is because for some reason the .NET code cannot access the private key, even though IIS can.

In the following code snippet, attempting to access the PrivateKey property for the .NET generated cert throws a System.Security.Cryptography.CryptographicException with the message "Keyset does not exist", while the OpenSSL generated cert is fine.

public void ReadDifferentCerts()
{
    var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
    store.Open(OpenFlags.ReadOnly);

    var certs = store.Certificates.Find(X509FindType.FindBySubjectName, "MyMachine", false);

    var dotNetCert = certs.Find(X509FindType.FindByIssuerName, "DotNetRootCA", false).First();
    var openSslCert = certs.Find(X509FindType.FindByIssuerName, "OpenSSLRootCA", false).First();
}

A lot of the solutions to this issue revolve around permissions to the key, and I can access the key if I import the pfx file to the Current User's store instead, but my questions is:

How is the OpenSSL version different? What flag do I need to set in the .NET version?

Update

I examined the pfx files with cert util -dump and there are some visible differences there. The OpenSSL cert uses Provider = Microsoft Enhanced Cryptographic Provider v1.0 and the .NET generated cert uses Provider = Microsoft Software Key Storage Provider and has an additional message that Private key is NOT plain text exportable

So how do I amend the .NET code to make it behave like the OpenSSL code?

c:\dev\certs>certutil -dump .\MyMachine-OpenSsl.pfx
Enter PFX password:

================ Certificate 0 ================
================ Begin Nesting Level 1 ================
Element 0:
Serial Number: 2f0f5985c17054b56c7fc42e8eb7048387536251
Issuer: C=GB, O=MyCompany, CN=OpenSSLRootCA
 NotBefore: 23/04/2025 09:36
 NotAfter: 23/04/2026 09:36
Subject: O=MyCompany, OU=DevSecOps, CN=LAPTOP-80
Non-root Certificate
Cert Hash(sha1): c065c3b09234933389714967f1d9dd2afc608f72
----------------  End Nesting Level 1  ----------------
  Provider = Microsoft Enhanced Cryptographic Provider v1.0
Encryption test passed
CertUtil: -dump command completed successfully.

c:\dev\certs>certutil -dump .\MyMachine-DotNet.pfx
Enter PFX password:

================ Certificate 0 ================
================ Begin Nesting Level 1 ================
Element 0:
Serial Number: 2a3781135c720ddfd1fae54ab3920bab915a8937
Issuer: C=GB, O=MyCompany, CN=DotNetRootCA
 NotBefore: 23/04/2025 15:39
 NotAfter: 23/04/2026 15:39
Subject: O=MyCompany, OU=DevSecOps, CN=LAPTOP-80
Non-root Certificate
Cert Hash(sha1): dabff030ecfcf8653b6ac5b45fa615fccf59cd19
----------------  End Nesting Level 1  ----------------
  Provider = Microsoft Software Key Storage Provider
Private key is NOT plain text exportable
Encryption test passed
CertUtil: -dump command completed successfully.

It is imported into the same store via the same user and the exact same method. As near as I can make it all the parameters are the same, they look almost identical in the store. The private key 100% exists in the pfx file because IIS can use it and the same file works in the Current User store.

C# code generation

public void GenerateAndWriteToDisk()
{
    var rootCA = BuildRootCertificate();
    
    Thread.Sleep(TimeSpan.FromSeconds(2));

    var serverCert = BuildServerCertificate(rootCA);

    var outputdir = @"C:\dev\certs";
    File.WriteAllBytes(Path.Combine(outputdir, "DotNetRootCACert.crt"), rootCA.Export(X509ContentType.Cert));
    File.WriteAllBytes(Path.Combine(outputdir, "LAPTOP-80-DotNet.pfx"), serverCert.Export(X509ContentType.Pfx, "word"));
}

public static X509Certificate2 BuildRootCertificate()
{
    using (var rsa = RSA.Create(3072))
    {
        var request = new CertificateRequest("C=GB,O=MyCompany,CN=DotNetRootCA", rsa, HashAlgorithmName.SHA256,
                RSASignaturePadding.Pkcs1);
        var subjectIdentifier = new X509SubjectKeyIdentifierExtension(new PublicKey(rsa), false);
        request.CertificateExtensions.Add(subjectIdentifier);
            request.CertificateExtensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(subjectIdentifier));
        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));

        var cert = request.CreateSelfSigned(DateTimeOffset.Now,
                DateTimeOffset.Now.AddDays(1825));
        return cert;
    }
}

public static X509Certificate2 BuildServerCertificate(X509Certificate2 rootCaCertificate)
{
    using (var rsa = RSA.Create(3072))
    {
        var request = new CertificateRequest("O=MyCompany,OU=DevSecOps,CN=MyMachine", rsa, HashAlgorithmName.SHA256,
                RSASignaturePadding.Pkcs1);
            
            request.CertificateExtensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromCertificate(rootCaCertificate, true, false));
        request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(new PublicKey(rsa), false));
        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false,
                0, false));

        var keyUsages = new X509KeyUsageExtension(
                X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment |
                X509KeyUsageFlags.DigitalSignature, false);
        request.CertificateExtensions.Add(keyUsages);

        var sanBuilder = new SubjectAlternativeNameBuilder();
        sanBuilder.AddDnsName("MyMachine");
        sanBuilder.AddDnsName("localhost");
        sanBuilder.AddIpAddress(IPAddress.Loopback);
        sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);

        request.CertificateExtensions.Add(sanBuilder.Build());

        var serialNumber = GetSerialNumber();

        var certificate = request.Create(rootCaCertificate, DateTimeOffset.UtcNow,
                DateTimeOffset.UtcNow.AddDays(365), serialNumber);

        return certificate.CopyWithPrivateKey(rsa);
    }
}

PowerShell / OpenSSL Generation

 function ExecOpenSsl([string]$sslArgs)
 {
     $pinfo = New-Object System.Diagnostics.ProcessStartInfo
     $pinfo.FileName = "openssl"
     $pinfo.RedirectStandardError = $true
     $pinfo.RedirectStandardOutput = $true
     $pinfo.UseShellExecute = $false
     $pinfo.CreateNoWindow = $true
     $pinfo.Arguments = $sslArgs
     $p = New-Object System.Diagnostics.Process
     $p.StartInfo = $pinfo
     $p.Start() | Out-Null
     $p.WaitForExit()
     $stdout = $p.StandardOutput.ReadToEnd()
     $stderr = $p.StandardError.ReadToEnd()
     Write-Host "stdout: $stdout"
     Write-Host "stderr: $stderr"
     Write-Host "exit code: " + $p.ExitCode
 }


 $dir = 'C:\dev\certs'
 $caKeyName = "$dir\OpenSslRootCaKey.key"
 $caCertName = "$dir\OpenSSlRootCaCert.crt"
 $serverKeyName = "$dir\OpenSslServerKey.key"
 $serverCsr = "$dir\OpenSslServerReq.csr"
 $serverExtension = "$dir\OpenSslServerExtensions.ext"
 $serverCert = "$dir\LAPTOP-80-OpenSsl.crt"
 $serverPfx = "$dir\LAPTOP-80-OpenSsl.pfx"


 Set-Location $dir

 $caPass = 'word'  # (New-Guid).ToString()
 $serverPass = 'word' #(New-Guid).ToString()

 ExecOpenSsl -sslArgs "genrsa -aes128 -passout pass:$($caPass) -out $caKeyName 3072"

 ExecOpenSsl -sslArgs "req -x509 -new -nodes -key $caKeyName -sha256 -days 1825 -out $caCertName -subj /CN=OpenSSLRootCA/O=MyCompany/C=GB/ -passin pass:$($caPass)"

 ExecOpenSsl -sslArgs "genrsa -aes128 -passout pass:$($serverPass) -out $serverKeyName 3072"

 $extension=@(
 "authorityKeyIdentifier=keyid,issuer"
 "basicConstraints=CA:FALSE"
 "keyUsage = digitalSignature, keyEncipherment, dataEncipherment"
 "subjectAltName = @alt_names"
 ""
 "[alt_names]"
 "DNS.1 = MyMachine"
 "DNS.2 = localhost"
 "IP.1 = 127.0.0.1"
 "IP.2 = ::1"
 )

 Set-Content -Path $serverExtension -Value ($extension -join ([Environment]::NewLine))  -NoNewline

 ExecOpenSsl -sslArgs "req -new -key $serverKeyName -passin pass:$($serverPass) -out $serverCsr -subj /CN=MyMachine/OU=DevSecOps/O=MyCompany/"

 ExecOpenSsl -sslArgs "x509 -req -in $serverCsr -CA $caCertName -CAkey $caKeyName -passin pass:$($caPass) -CAcreateserial -out $serverCert -days 365 -sha256 -extfile $serverExtension"

 ExecOpenSsl -sslArgs "pkcs12 -export -out $serverPfx -inkey $serverKeyName -in $serverCert -passin pass:$($serverPass) -passout pass:$($serverPass)"
6
  • OpenSSL version uses a key file, I don't see how it's remotely comparable to using the machine/user store, which use a proper permission system. Did you give this user the correct permissions on the private key codyhosterman.com/2019/06/… Commented 2 days ago
  • @Charlieface yes the OpenSLL version uses a key file for the generation of the cert, but both methods result in a .crt file with the public key of the root certificate and a .pfx file which contains the public & private key of the server certificate and both are imported into the local machine store by the same user and the same method.
    – ste-fu
    Commented 2 days ago
  • Sorry - missed the method where the files are generated in the C#. Added now
    – ste-fu
    Commented 2 days ago
  • 1
    Probably: The .NET created PFX ends up with the private key in CNG, and OpenSSL ends up in CAPI. cert.PrivateKey is obsolete, don't use it. Does cert.GetRSAPrivateKey() return a working value?
    – bartonjs
    Commented 2 days ago
  • I'm sorry I don't see that. The code you are complaining about (the first block) reads from the machine store Commented 2 days ago

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.