diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b3759d43..5f086f627 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' - name: Build Unit Tests .NET run: dotnet build -f net9.0 test/Renci.SshNet.Tests/ @@ -35,7 +38,7 @@ jobs: -p:CoverletOutput=../../coverlet/linux_unit_test_net_9_coverage.xml \ test/Renci.SshNet.Tests/ - - name: Run Integration Tests .NET + - name: Run Integration Tests .NET 1 run: | dotnet test \ -f net9.0 \ @@ -44,7 +47,33 @@ jobs: --logger GitHubActions \ -p:CollectCoverage=true \ -p:CoverletOutputFormat=cobertura \ - -p:CoverletOutput=../../coverlet/linux_integration_test_net_9_coverage.xml \ + -p:CoverletOutput=../../coverlet/linux_integration_test_net_9_coverage_1.xml \ + test/Renci.SshNet.IntegrationTests/ + + - name: Run Integration Tests .NET 2 + run: | + dotnet test \ + -f net9.0 \ + --logger "console;verbosity=normal" \ + --logger GitHubActions \ + --filter "Name=MLKem768X25519Sha256" \ + -p:DefineConstants="$(9);Test_BCL_MLKem" \ + -p:CollectCoverage=true \ + -p:CoverletOutputFormat=cobertura \ + -p:CoverletOutput=../../coverlet/linux_integration_test_net_9_coverage_2.xml \ + test/Renci.SshNet.IntegrationTests/ + + - name: Run Integration Tests .NET 3 + run: | + dotnet test \ + -f net9.0 \ + --logger "console;verbosity=normal" \ + --logger GitHubActions \ + --filter "Name=MLKem768X25519Sha256" \ + -p:DefineConstants="Test_BouncyCastle_MLKem" \ + -p:CollectCoverage=true \ + -p:CoverletOutputFormat=cobertura \ + -p:CoverletOutput=../../coverlet/linux_integration_test_net_9_coverage_3.xml \ test/Renci.SshNet.IntegrationTests/ - name: Archive Coverlet Results @@ -63,6 +92,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' - name: Build Solution run: dotnet build Renci.SshNet.sln @@ -114,6 +146,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' - name: Setup WSL2 uses: Vampire/setup-wsl@6a8db447be7ed35f2f499c02c6e60ff77ef11278 # v6.0.0 @@ -128,15 +163,41 @@ jobs: podman build -t renci-ssh-tests-server-image -f test/Renci.SshNet.IntegrationTests/Dockerfile test/Renci.SshNet.IntegrationTests/ podman run --rm -h renci-ssh-tests-server -d -p 2222:22 renci-ssh-tests-server-image - - name: Run Integration Tests .NET Framework + - name: Run Integration Tests .NET Framework 1 + run: + dotnet test ` + -f net48 ` + --logger "console;verbosity=normal" ` + --logger GitHubActions ` + -p:CollectCoverage=true ` + -p:CoverletOutputFormat=cobertura ` + -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_4_8_coverage_1.xml ` + test\Renci.SshNet.IntegrationTests\ + + - name: Run Integration Tests .NET Framework 2 run: dotnet test ` -f net48 ` --logger "console;verbosity=normal" ` --logger GitHubActions ` + --filter "Name=MLKem768X25519Sha256" ` + -p:DefineConstants="Test_BCL_MLKem" ` -p:CollectCoverage=true ` -p:CoverletOutputFormat=cobertura ` - -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_4_8_coverage.xml ` + -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_4_8_coverage_2.xml ` + test\Renci.SshNet.IntegrationTests\ + + - name: Run Integration Tests .NET Framework 3 + run: + dotnet test ` + -f net48 ` + --logger "console;verbosity=normal" ` + --logger GitHubActions ` + --filter "Name=MLKem768X25519Sha256" ` + -p:DefineConstants="Test_BouncyCastle_MLKem" ` + -p:CollectCoverage=true ` + -p:CoverletOutputFormat=cobertura ` + -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_4_8_coverage_3.xml ` test\Renci.SshNet.IntegrationTests\ - name: Archive Coverlet Results @@ -156,6 +217,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' - name: Setup WSL2 uses: Vampire/setup-wsl@6a8db447be7ed35f2f499c02c6e60ff77ef11278 # v6.0.0 @@ -170,15 +234,41 @@ jobs: podman build -t renci-ssh-tests-server-image -f test/Renci.SshNet.IntegrationTests/Dockerfile test/Renci.SshNet.IntegrationTests/ podman run --rm -h renci-ssh-tests-server -d -p 2222:22 renci-ssh-tests-server-image - - name: Run Integration Tests .NET + - name: Run Integration Tests .NET 1 + run: + dotnet test ` + -f net9.0 ` + --logger "console;verbosity=normal" ` + --logger GitHubActions ` + -p:CollectCoverage=true ` + -p:CoverletOutputFormat=cobertura ` + -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_9_coverage_1.xml ` + test\Renci.SshNet.IntegrationTests\ + + - name: Run Integration Tests .NET 2 + run: + dotnet test ` + -f net9.0 ` + --logger "console;verbosity=normal" ` + --logger GitHubActions ` + --filter "Name=MLKem768X25519Sha256" ` + -p:DefineConstants="Test_BCL_MLKem" ` + -p:CollectCoverage=true ` + -p:CoverletOutputFormat=cobertura ` + -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_9_coverage_2.xml ` + test\Renci.SshNet.IntegrationTests\ + + - name: Run Integration Tests .NET 3 run: dotnet test ` -f net9.0 ` --logger "console;verbosity=normal" ` --logger GitHubActions ` + --filter "Name=MLKem768X25519Sha256" ` + -p:DefineConstants="Test_BouncyCastle_MLKem" ` -p:CollectCoverage=true ` -p:CoverletOutputFormat=cobertura ` - -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_9_coverage.xml ` + -p:CoverletOutput=..\..\coverlet\windows_integration_test_net_9_coverage_3.xml ` test\Renci.SshNet.IntegrationTests\ - name: Archive Coverlet Results diff --git a/Directory.Packages.props b/Directory.Packages.props index 8138199d5..1f884229e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/src/Renci.SshNet/Renci.SshNet.csproj b/src/Renci.SshNet/Renci.SshNet.csproj index 446865229..b7fb361ee 100644 --- a/src/Renci.SshNet/Renci.SshNet.csproj +++ b/src/Renci.SshNet/Renci.SshNet.csproj @@ -40,6 +40,10 @@ true + + $(DefineConstants);Test_BCL_MLKem + + @@ -49,10 +53,11 @@ - - + + + True diff --git a/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.BclImpl.cs b/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.BclImpl.cs new file mode 100644 index 000000000..6cc22e522 --- /dev/null +++ b/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.BclImpl.cs @@ -0,0 +1,36 @@ +using System; +using System.Security.Cryptography; + +namespace Renci.SshNet.Security +{ + internal sealed partial class KeyExchangeMLKem768X25519Sha256 + { + private sealed class MLKemBclImpl : Impl + { + private MLKem _mlkem; + + public override byte[] GenerateClientPublicKey() + { + _mlkem = MLKem.GenerateKey(MLKemAlgorithm.MLKem768); + return _mlkem.ExportEncapsulationKey(); + } + + public override byte[] CalculateAgreement(byte[] serverPublicKey) + { + var mlkemSecret = new byte[MLKemAlgorithm.MLKem768.SharedSecretSizeInBytes]; + _mlkem.Decapsulate(serverPublicKey.AsSpan(0, MLKemAlgorithm.MLKem768.CiphertextSizeInBytes), mlkemSecret); + return mlkemSecret; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _mlkem?.Dispose(); + } + + base.Dispose(disposing); + } + } + } +} diff --git a/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.BouncyCastleImpl.cs b/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.BouncyCastleImpl.cs new file mode 100644 index 000000000..73eb3c08f --- /dev/null +++ b/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.BouncyCastleImpl.cs @@ -0,0 +1,36 @@ +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Kems; +using Org.BouncyCastle.Crypto.Parameters; + +using Renci.SshNet.Abstractions; + +namespace Renci.SshNet.Security +{ + internal sealed partial class KeyExchangeMLKem768X25519Sha256 + { + private sealed class MLKemBouncyCastleImpl : Impl + { + private MLKemDecapsulator _mlkemDecapsulator; + + public override byte[] GenerateClientPublicKey() + { + var mlkem768KeyPairGenerator = new MLKemKeyPairGenerator(); + mlkem768KeyPairGenerator.Init(new MLKemKeyGenerationParameters(CryptoAbstraction.SecureRandom, MLKemParameters.ml_kem_768)); + var mlkem768KeyPair = mlkem768KeyPairGenerator.GenerateKeyPair(); + + _mlkemDecapsulator = new MLKemDecapsulator(MLKemParameters.ml_kem_768); + _mlkemDecapsulator.Init(mlkem768KeyPair.Private); + + return ((MLKemPublicKeyParameters)mlkem768KeyPair.Public).GetEncoded(); + } + + public override byte[] CalculateAgreement(byte[] serverPublicKey) + { + var mlkemSecret = new byte[_mlkemDecapsulator.SecretLength]; + _mlkemDecapsulator.Decapsulate(serverPublicKey, 0, _mlkemDecapsulator.EncapsulationLength, mlkemSecret, 0, _mlkemDecapsulator.SecretLength); + + return mlkemSecret; + } + } + } +} diff --git a/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.cs b/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.cs index 606e3a250..77cf83b7a 100644 --- a/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.cs +++ b/src/Renci.SshNet/Security/KeyExchangeMLKem768X25519Sha256.cs @@ -1,8 +1,6 @@ using System.Globalization; -using System.Linq; +using System.Security.Cryptography; -using Org.BouncyCastle.Crypto.Generators; -using Org.BouncyCastle.Crypto.Kems; using Org.BouncyCastle.Crypto.Parameters; using Renci.SshNet.Abstractions; @@ -11,9 +9,15 @@ namespace Renci.SshNet.Security { - internal sealed class KeyExchangeMLKem768X25519Sha256 : KeyExchangeECCurve25519 + internal sealed partial class KeyExchangeMLKem768X25519Sha256 : KeyExchangeECCurve25519 { - private MLKemDecapsulator _mlkemDecapsulator; +#if Test_BCL_MLKem + private MLKemBclImpl _mlkemImpl; +#elif Test_BouncyCastle_MLKem + private MLKemBouncyCastleImpl _mlkemImpl; +#else + private Impl _mlkemImpl; +#endif /// /// Gets algorithm name. @@ -41,14 +45,21 @@ protected override void StartImpl() Session.KeyExchangeHybridReplyMessageReceived += Session_KeyExchangeHybridReplyMessageReceived; - var mlkem768KeyPairGenerator = new MLKemKeyPairGenerator(); - mlkem768KeyPairGenerator.Init(new MLKemKeyGenerationParameters(CryptoAbstraction.SecureRandom, MLKemParameters.ml_kem_768)); - var mlkem768KeyPair = mlkem768KeyPairGenerator.GenerateKeyPair(); - - _mlkemDecapsulator = new MLKemDecapsulator(MLKemParameters.ml_kem_768); - _mlkemDecapsulator.Init(mlkem768KeyPair.Private); - - var mlkem768PublicKey = ((MLKemPublicKeyParameters)mlkem768KeyPair.Public).GetEncoded(); +#if Test_BCL_MLKem + _mlkemImpl = new MLKemBclImpl(); +#elif Test_BouncyCastle_MLKem + _mlkemImpl = new MLKemBouncyCastleImpl(); +#else + if (MLKem.IsSupported) + { + _mlkemImpl = new MLKemBclImpl(); + } + else + { + _mlkemImpl = new MLKemBouncyCastleImpl(); + } +#endif + var mlkem768PublicKey = _mlkemImpl.GenerateClientPublicKey(); var x25519PublicKey = _impl.GenerateClientPublicKey(); @@ -100,20 +111,28 @@ private void HandleServerHybridReply(byte[] hostKey, byte[] serverExchangeValue, _hostKey = hostKey; _signature = signature; - if (serverExchangeValue.Length != _mlkemDecapsulator.EncapsulationLength + X25519PublicKeyParameters.KeySize) + if (serverExchangeValue.Length != MLKemAlgorithm.MLKem768.CiphertextSizeInBytes + X25519PublicKeyParameters.KeySize) { throw new SshConnectionException( string.Format(CultureInfo.CurrentCulture, "Bad S_Reply length: {0}.", serverExchangeValue.Length), DisconnectReason.KeyExchangeFailed); } - var mlkemSecret = new byte[_mlkemDecapsulator.SecretLength]; - - _mlkemDecapsulator.Decapsulate(serverExchangeValue, 0, _mlkemDecapsulator.EncapsulationLength, mlkemSecret, 0, _mlkemDecapsulator.SecretLength); + var mlkemSecret = _mlkemImpl.CalculateAgreement(serverExchangeValue); - var x25519Agreement = _impl.CalculateAgreement(serverExchangeValue.Take(_mlkemDecapsulator.EncapsulationLength, X25519PublicKeyParameters.KeySize)); + var x25519Agreement = _impl.CalculateAgreement(serverExchangeValue.Take(MLKemAlgorithm.MLKem768.CiphertextSizeInBytes, X25519PublicKeyParameters.KeySize)); SharedKey = CryptoAbstraction.HashSHA256(mlkemSecret.Concat(x25519Agreement)); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _mlkemImpl?.Dispose(); + } + + base.Dispose(disposing); + } } }