Search code examples
c#windowscertificatersaprivate-key

D-Parameter of RSA change depending on how you access the private key of a certificate


I hope someone can explain to me where I have made a mistake. I always thought that when I export a certificate with a private key and import it again, the private key is stable and does not change. Especially across computers.

Now I have been proven wrong and I don't understand it.

Given a certificate Z. Which contains a private key pk. I import this certificate onto a computer C1 and onto a computer C2.

I get the parameters of the private key on both.

Result on C1:
Exponent : {1, 0, 1}
Modulus  : {148, 19, 118, 153...}
P        : {246, 42, 172, 195...}
Q        : {153, 253, 180, 23...}
DP       : {161, 179, 194, 172...}
DQ       : {63, 22, 42, 14...}
InverseQ : {170, 228, 238, 93...}
D        : {60, 36, 199, 168...}

Result on C2:
Exponent : {1, 0, 1}
Modulus  : {148, 19, 118, 153...}
P        : {246, 42, 172, 195...}
Q        : {153, 253, 180, 23...}
DP       : {161, 179, 194, 172...}
DQ       : {63, 22, 42, 14...}
InverseQ : {170, 228, 238, 93...}
D        : {0, 233, 203, 106...}

Used code:

using (var store = new X509Store(storeNameEnum, storeLocationEnum))
{

  X509Certificate2Collection certificates = null;
  store.Open(OpenFlags.ReadOnly);
  var certificates = store.Certificates;
  var cert = certificates.Cast<X509Certificate2>().Single(c => c.SubjectName.Name.ToLower().Contains(subject.ToLower()));
  var rsa = cert.GetRSAPrivateKey();
  var params = rsa.ExportParameters(true);
}

The D-parts of the RSA parameters differ. Why?

On computer C2 I get the same D as on C1 when I call the following line:

var params (cert.PrivateKey as RSACryptoServiceProvider).ExportParameters(true).D;

But I can't use that because I'm writing a .NetStandard library that is also used in a Net5 project.

Next I tried to read in the PFX directly from file on C2 and check the PK. The following lines give me the PK as on C1.

string pfxPassword = "pw";
Pkcs12Store pkcs12 = new Pkcs12Store(new FileStream(@"C:\tmp\cert.p12", FileMode.Open, FileAccess.Read), pfxPassword.ToArray());
var cert = pkcs12.GetCertificate("certName");
var keyEntry = pkcs12.GetKey("certName");    
RSAParameters rsaParams = Org.BouncyCastle.Security.DotNetUtilities.ToRSAParameters((RsaPrivateCrtKeyParameters)keyEntry.Key);
Console.WriteLine($"=> {String.Join(" ", rsaParams.D)}");

However, when I then convert it to the certifiact in order to import it into the store, the PK changes again as it was originally on C2.

X509Certificate2 certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2(Org.BouncyCastle.Security.DotNetUtilities.ToX509Certificate(cert.Certificate));
RSACryptoServiceProvider csp = new RSACryptoServiceProvider(new CspParameters(1, "Microsoft Strong Cryptographic Provider", new Guid().ToString(), new System.Security.AccessControl.CryptoKeySecurity(), null));                       
csp.ImportParameters(rsaParams);
certificate.PrivateKey = csp;            
Console.WriteLine($"=> {String.Join(" ", certificate.GetRSAPrivateKey().ExportParameters(true).D)}");

Apparently some computers are set to always give the same result as C1 others always give the same result as C2. But why do they deliver different results at all? What influences this behaviour?

I am grateful for any hint.


Solution

  • Basically, the D value doesn't matter, and you're seeing a consequence of that.

    "Did you just say the D value doesn't matter? Isn't RSA based on m == modpow(modpow(m, e, n), d, n)?"

    Yep, and yep. But the Chinese Remainder Theorem provides for a more efficient implementation for modpow(m, d, n), so no one really bothers with D.

    The other thing that's going on, is that when an RSA private key is imported you have a couple of choices: 1) verify that n == (p * q) and the d/dp/dq/qInv make sense given n/e/p/q, fail if they don't, 2) import the key on faith, deal with consequences of inconsistency ("garbage in, garbage out"), 3) do (1) but fix any incorrect data.

    OK, so we have the premise of why the values might change (strategy (3)), but why are they actually changing?

    Because there are at least two different common answers for D. ("Isn't D unique?" no. "Didn't you say D doesn't matter?" OK, so it matters in computing the CRT parameters, then it stops mattering.)

    The original RSA paper defined D as the modular multiplicative inverse of e modulo the Euler totient function of N. The usual symbol for the Euler totient function is the Greek letter phi. Many smart people later, the statement got changed to D being the modular multiplicative inverse of e modulo the Carmichael function of N. The usual symbol for the Carmichael function is the Greek letter lambda.

    The difference is sort of a squares-vs-rectangles thing. All D-phi values work for RSA, because e * D-phi === 1 (mod lambda(N)). Since all D-lambda values also work for RSA, but don't adhere to e * D-lambda === 1 (mod phi(N)), the formula got rewritten.

    OK, there's the background, so what's happening?

    • Windows CAPI (powers RSACryptoServiceProvider on Windows, RSA.Create() on .NET Framework) generates keys using lambda, but preserves the D value across import/export.
    • OpenSSL (powers RSA classes on Linux) generates keys using phi, but preserves the D value across import/export.
    • Windows CNG (powers RSACng on Windows, RSA.Create() on .NET5/.NET Core on Windows) generates keys using phi, but discards D on import and recomputes it from N/E/P/Q for export.
      • (There's some nuance here... I feel like CNG changed to maybe preserve the D value around Windows 10 20H1.)
    • I don't remember what Android does (probably OpenSSL behaviors), or what macOS does.

    So, my guess is that C1 and C2 are running on different OSes (or different versions of the same OS).

    https://github.com/dotnet/runtime/commit/700a07cae19fe64649c2fb4c6c10e6b9aa85dc29 shows how we dealt with it in the test suite for .NET. For application code, my recommendation is to just trust the systems.