dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.27k stars 4.73k forks source link

Performance downgrade on RSA Export methods on system with OpenSSL 3+ #102603

Closed alxbsv closed 5 months ago

alxbsv commented 5 months ago

First call to any of the RSA.Export* functions are around 4-5 times slower on Linux systems with OpenSSL 3.0 and later, than on Windows or distributions with OpenSSL 1.1. Only happens for key length of 2048 and larger. First call is where key generation happens, so, in other words, RSA key generation got ~5 times slower since OpenSSL 3.

Most likely cause is the corresponding update in OpenSSL, see https://github.com/openssl/openssl/issues/13370.

https://github.com/dotnet/runtime/issues/97727 is a similar issue regarding performance downgrade in Import* methods.

Below are approximate measurements done on my machine, and, even considering that generation time is not consistent. these should be enough to demonstrate the issue:

ExportParameters(false), key length 2048

OS OpenSSL RSA type Op/s
Windows 10 - RSABCrypt 19-20
Ubuntu 20 (WSL) 1.1 RSAOpenSsl 19-20
Ubuntu 22 (WSL) 3.* RSAOpenSsl 4-5
Ubuntu 22 (Docker) 3.* RSAOpenSsl 4-5
Ubuntu 24 (Docker) 3.* RSAOpenSsl 4-5

ExportParameters(false), key length 1024

OS OpenSSL RSA type Op/s
Windows 10 - RSABCrypt 90-100
Ubuntu 22 (WSL) 3.* RSAOpenSsl 90-100
Ubuntu 22 (Docker) 3.* RSAOpenSsl 90-100
Ubuntu 24 (Docker) 3.* RSAOpenSsl 90-100

Tested mostly on .NET 8.0.5, but for Windows and Ubuntu 20, numbers were similar between .NET 8, 7, 5 and Core 3.

Testing code used:

public static class Program
{
    static void LogRsaType()
    {
        var rsaTypes = new List<string>();
        var rsa = RSA.Create();
        rsaTypes.Add(rsa.GetType().Name);
        if (rsa.GetType().Name == "RSAWrapper")
        {
            var underType = rsa.GetType()
                .GetField("_wrapped", BindingFlags.NonPublic | BindingFlags.Instance)?
                .GetValue(rsa)?
                .GetType().Name;

            if (underType != null)
                rsaTypes.Add(underType);
        }

        Console.WriteLine($"RSA types: {string.Join(" -> ", rsaTypes)}");
    }

    static void Measure<T>(string name, int count, Func<T> factory, Action<T> action)
    {
        var totalMs = 0d;
        for (var i = 0; i < count; i++)
        {
            var instance = factory();
            var watch = Stopwatch.StartNew();
            action(instance);
            totalMs += watch.Elapsed.TotalMilliseconds;
        }

        var elapsed = TimeSpan.FromMilliseconds(totalMs / count);
        Console.WriteLine($"{name}: {elapsed} ({1 / elapsed.TotalSeconds:F2} per second)");
    }

    public static void Main()
    {
        LogRsaType();

        Measure(
            "RSA.ExportParameters [1024]", 100,
            () => RSA.Create(1024),
            alg => alg.ExportParameters(false)
        );

        Measure(
            "RSA.ExportParameters [2048]", 100,
            () => RSA.Create(2048),
            alg => alg.ExportParameters(false)
        );
    }
}

Alternative Benchmark.NET variant, showing similar results:

[MyJob(RuntimeMoniker.NetCoreApp31)]
[MyJob(RuntimeMoniker.Net50)]
[MyJob(RuntimeMoniker.Net70)]
[MyJob(RuntimeMoniker.Net80, baseline: true)]
public class ExportParameters: Benchmark
{
    protected const int RsaKeySize = 2048;
    protected static readonly ECCurve EcdsaCurve = ECCurve.NamedCurves.nistP256;

    private RSA _rsa = null!;
    private ECDsa _ecdsa = null!;

    [IterationSetup]
    public void IterationSetup()
    {
        _rsa = RSA.Create(RsaKeySize);
        _ecdsa = ECDsa.Create(EcdsaCurve);
    }

    [Benchmark]
    public void Rsa() => _rsa.ExportParameters(false);

    [Benchmark]
    public void Ecdsa() => _ecdsa.ExportParameters(false);
}
dotnet-policy-service[bot] commented 5 months ago

Tagging subscribers to this area: @dotnet/area-system-security, @bartonjs, @vcsjones See info in area-owners.md if you want to be subscribed.

vcsjones commented 5 months ago

Can you include the code that you used to create those measurements?

alxbsv commented 5 months ago

@vcsjones, added the code.

I can also try to include tables from Benchmark.NET, but don't have access to Ubuntu 20 at the moment. Also, not sure it's needed - problem should be easily reproducible.

vcsjones commented 5 months ago

Cryptographic objects like RSA and ECDsa do not generate a key in their constructor or Create. They generate a key when they are needed, if a key has not been supplied.

Since you are not supplying a key, you are generating a key in ExportParameters. And since you are using IterationSetup, not GlobalSetup, each run of the benchmark generates a key.

If we change the benchmark to force generating the key before it starts measuring generating the key, ExportParameters does not differ meaningfully between OpenSSL 1.1 and 3.0 for me.

So, really your benchmark is showing that key generation is slower. As you noted, that is a known behavior of OpenSSL 3.0, and there isn’t much we can do about that.

using BenchmarkDotNet.Attributes;
using System.Security.Cryptography;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run(typeof(Program).Assembly);

public class ExportParameters
{
    protected const int RsaKeySize = 2048;
    protected static readonly ECCurve EcdsaCurve = ECCurve.NamedCurves.nistP256;

    private RSA _rsa = null!;
    private ECDsa _ecdsa = null!;

    [GlobalSetup]
    public void GlobalSetup()
    {
        _rsa = RSA.Create(RsaKeySize);
        _rsa.ExportParameters(false);
        _ecdsa = ECDsa.Create(EcdsaCurve);
        _ecdsa.ExportParameters(false);
    }

    [Benchmark]
    public void Rsa() => _rsa.ExportParameters(false);

    [Benchmark]
    public void Ecdsa() => _ecdsa.ExportParameters(false);
}
alxbsv commented 5 months ago

@vcsjones, yes, the issue is about the key generation speed, not several Export* calls on the same RSA instance. The use case is an endpoint for generating new RSA keys and saving them for later and it's now getting several times slower. Apologies if that wasn't clear.

I saw you've made some improvements regarding the Import* performance downgrade after OpenSSL 3.0 - are any similar workarounds possible in this case? Like forcing the usage of the old algorithm for example?

Since RSAOpenSsl implementation got an "update", yet Windows RSABCrypt version remains the same - this means generation algorithms are quite different now. If no changes will be done to RSAOpenSsl, shouldn't RSABCrypt be updated to conform?

vcsjones commented 5 months ago

The only way we could fix this is to implement our own RSA key generator, which I don’t think is something we are willing to do since it is table stakes. We were able to fix the import because it only required implementing our own key check mechanism.

alxbsv commented 5 months ago

Ok, thanks for the quick turnaround.