From 380ce34f132205b230094685e0f1fa5e9f01407d Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Sat, 24 Aug 2024 09:00:40 +0200 Subject: [PATCH 1/5] Support chacha20-poly1305@openssh.com. --- src/Tmds.Ssh/AlgorithmNames.cs | 2 + src/Tmds.Ssh/ChaCha20Poly1305PacketDecoder.cs | 130 ++++++++++++++++++ .../ChaCha20Poly1305PacketEncDecBase.cs | 44 ++++++ src/Tmds.Ssh/ChaCha20Poly1305PacketEncoder.cs | 68 +++++++++ src/Tmds.Ssh/ECDHKeyExchange.cs | 41 ++++-- src/Tmds.Ssh/EncryptionAlgorithm.cs | 8 ++ src/Tmds.Ssh/SshChannel.cs | 12 +- src/Tmds.Ssh/SshClientSettings.Defaults.cs | 2 +- test/Tmds.Ssh.Tests/RemoteProcessTests.cs | 7 +- test/Tmds.Ssh.Tests/SshClientSettingsTests.cs | 4 +- test/Tmds.Ssh.Tests/Tmds.Ssh.Tests.csproj | 4 + test/Tmds.Ssh.Tests/xunit.runner.json | 5 + 12 files changed, 305 insertions(+), 22 deletions(-) create mode 100644 src/Tmds.Ssh/ChaCha20Poly1305PacketDecoder.cs create mode 100644 src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs create mode 100644 src/Tmds.Ssh/ChaCha20Poly1305PacketEncoder.cs create mode 100644 test/Tmds.Ssh.Tests/xunit.runner.json diff --git a/src/Tmds.Ssh/AlgorithmNames.cs b/src/Tmds.Ssh/AlgorithmNames.cs index e2b07d5..8cf814a 100644 --- a/src/Tmds.Ssh/AlgorithmNames.cs +++ b/src/Tmds.Ssh/AlgorithmNames.cs @@ -53,6 +53,8 @@ static class AlgorithmNames // TODO: rename to KnownNames public static Name Aes128Gcm => new Name(Aes128GcmBytes); private static readonly byte[] Aes256GcmBytes = "aes256-gcm@openssh.com"u8.ToArray(); public static Name Aes256Gcm => new Name(Aes256GcmBytes); + private static readonly byte[] ChaCha20Poly1305Bytes = "chacha20-poly1305@openssh.com"u8.ToArray(); + public static Name ChaCha20Poly1305 => new Name(ChaCha20Poly1305Bytes); // KDF algorithms: private static readonly byte[] BCryptBytes = "bcrypt"u8.ToArray(); diff --git a/src/Tmds.Ssh/ChaCha20Poly1305PacketDecoder.cs b/src/Tmds.Ssh/ChaCha20Poly1305PacketDecoder.cs new file mode 100644 index 0000000..e50cbc8 --- /dev/null +++ b/src/Tmds.Ssh/ChaCha20Poly1305PacketDecoder.cs @@ -0,0 +1,130 @@ +// This file is part of Tmds.Ssh which is released under MIT. +// See file LICENSE for full license details. + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace Tmds.Ssh; + +sealed class ChaCha20Poly1305PacketDecoder : ChaCha20Poly1305PacketEncDecBase, IPacketDecoder +{ + private readonly SequencePool _sequencePool; + private int _currentPacketLength = -1; + + public ChaCha20Poly1305PacketDecoder(SequencePool sequencePool, byte[] key) : + base(key) + { + _sequencePool = sequencePool; + } + + public void Dispose() + { } + + public bool TryDecodePacket(Sequence receiveBuffer, uint sequenceNumber, int maxLength, out Packet packet) + { + packet = new Packet(null); + + // Wait for the length. + if (receiveBuffer.Length < LengthSize) + { + return false; + } + + // Decrypt length. + int packetLength = _currentPacketLength; + Span length_unencrypted = stackalloc byte[LengthSize]; + if (packetLength == -1) + { + ConfigureCiphers(sequenceNumber); + + Span length_encrypted = stackalloc byte[LengthSize]; + if (receiveBuffer.FirstSpan.Length >= LengthSize) + { + receiveBuffer.FirstSpan.Slice(0, LengthSize).CopyTo(length_encrypted); + } + else + { + receiveBuffer.AsReadOnlySequence().Slice(0, LengthSize).CopyTo(length_encrypted); + } + + LengthCipher.ProcessBytes(length_encrypted, length_unencrypted); + + // Verify the packet length isn't too long and properly padded. + uint packet_length = BinaryPrimitives.ReadUInt32BigEndian(length_unencrypted); + if (packet_length > maxLength || (packet_length % PaddTo) != 0) + { + ThrowHelper.ThrowProtocolPacketTooLong(); + } + + _currentPacketLength = packetLength = (int)packet_length; + } + else + { + BinaryPrimitives.WriteInt32BigEndian(length_unencrypted, _currentPacketLength); + } + + // Wait for the full encrypted packet. + int total_length = LengthSize + packetLength + TagSize; + if (receiveBuffer.Length < total_length) + { + return false; + } + + // Check the mac. + ReadOnlySequence receiveBufferROSequence = receiveBuffer.AsReadOnlySequence(); + ReadOnlySequence hashed = receiveBufferROSequence.Slice(0, LengthSize + packetLength); + Span packetTag = stackalloc byte[TagSize]; + receiveBufferROSequence.Slice(LengthSize + packetLength, TagSize).CopyTo(packetTag); + if (hashed.IsSingleSegment) + { + Mac.BlockUpdate(hashed.FirstSpan); + } + else + { + foreach (var memory in hashed) + { + Mac.BlockUpdate(memory.Span); + } + } + Span tag = stackalloc byte[TagSize]; + Mac.DoFinal(tag); + if (!CryptographicOperations.FixedTimeEquals(packetTag, tag)) + { + throw new CryptographicException(); + } + + int decodedLength = total_length - TagSize; + Sequence decoded = _sequencePool.RentSequence(); + Span dst = decoded.AllocGetSpan(decodedLength); + + // Decrypt length. + length_unencrypted.CopyTo(dst); + + // Decrypt payload. + Span plaintext = dst.Slice(LengthSize, packetLength); + ReadOnlySequence ciphertext = receiveBufferROSequence.Slice(LengthSize, packetLength); + if (ciphertext.IsSingleSegment) + { + PayloadCipher.ProcessBytes(ciphertext.FirstSpan, plaintext); + } + else + { + foreach (var memory in ciphertext) + { + PayloadCipher.ProcessBytes(memory.Span, plaintext); + plaintext = plaintext.Slice(memory.Length); + } + } + + decoded.AppendAlloced(decodedLength); + packet = new Packet(decoded); + + receiveBuffer.Remove(total_length); + + _currentPacketLength = -1; // start decoding a new packet + + return true; + } +} diff --git a/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs b/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs new file mode 100644 index 0000000..afdcab3 --- /dev/null +++ b/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs @@ -0,0 +1,44 @@ +// This file is part of Tmds.Ssh which is released under MIT. +// See file LICENSE for full license details. + +using System; +using System.Buffers.Binary; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Macs; +using Org.BouncyCastle.Crypto.Parameters; + +namespace Tmds.Ssh; + +class ChaCha20Poly1305PacketEncDecBase +{ + public const int TagSize = 16; // Poly1305 hash length. + protected const int PaddTo = 8; // We're not a block cipher. Padd to 8 octets per rfc4253. + protected const int LengthSize = 4; // SSH packet length field is 4 bytes. + + protected readonly ChaCha7539Engine LengthCipher; + protected readonly ChaCha7539Engine PayloadCipher; + protected readonly Poly1305 Mac; + private readonly byte[] _K1; + private readonly byte[] _K2; + + protected ChaCha20Poly1305PacketEncDecBase(byte[] key) + { + _K1 = key.AsSpan(32, 32).ToArray(); + _K2 = key.AsSpan(0, 32).ToArray(); + LengthCipher = new(); + PayloadCipher = new(); + Mac = new(); + } + + protected void ConfigureCiphers(uint sequenceNumber) + { + Span iv = stackalloc byte[12]; + Span polyKey = stackalloc byte[64]; + BinaryPrimitives.WriteUInt64BigEndian(iv[4..], sequenceNumber); + LengthCipher.Init(forEncryption: true, new ParametersWithIV(new KeyParameter(_K1), iv)); + PayloadCipher.Init(forEncryption: true, new ParametersWithIV(new KeyParameter(_K2), iv)); + // note: encrypting 64 bytes increments the ChaCha20 block counter. + PayloadCipher.ProcessBytes(input: polyKey, output: polyKey); + Mac.Init(new KeyParameter(polyKey[..32])); + } +} diff --git a/src/Tmds.Ssh/ChaCha20Poly1305PacketEncoder.cs b/src/Tmds.Ssh/ChaCha20Poly1305PacketEncoder.cs new file mode 100644 index 0000000..e53d98f --- /dev/null +++ b/src/Tmds.Ssh/ChaCha20Poly1305PacketEncoder.cs @@ -0,0 +1,68 @@ +// This file is part of Tmds.Ssh which is released under MIT. +// See file LICENSE for full license details. + +using System; +using System.Buffers; + +namespace Tmds.Ssh; + +// https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.chacha20poly1305?annotate=HEAD +sealed class ChaCha20Poly1305PacketEncoder : ChaCha20Poly1305PacketEncDecBase, IPacketEncoder +{ + public ChaCha20Poly1305PacketEncoder(byte[] key) : + base(key) + { } + + public void Dispose() + { } + + public void Encode(uint sequenceNumber, Packet packet, Sequence output) + { + using var pkt = packet.Move(); // Dispose the packet. + + ConfigureCiphers(sequenceNumber); + + // Padding. + uint payload_length = (uint)pkt.PayloadLength; + // PT (Plain Text) + // byte padding_length; // 4 <= padding_length < 256 + // byte[n1] payload; // n1 = packet_length-padding_length-1 + // byte[n2] random_padding; // n2 = padding_length + byte padding_length = IPacketEncoder.DeterminePaddingLength(payload_length + 1, multipleOf: PaddTo); + pkt.WriteHeaderAndPadding(padding_length); + + var unencrypted_packet = pkt.AsReadOnlySequence(); + ReadOnlySpan packet_length = unencrypted_packet.FirstSpan.Slice(0, LengthSize); // packet_length + ReadOnlySequence pt = unencrypted_packet.Slice(LengthSize); // PT (Plain Text) + + int textLength = (int)pt.Length; + int encodedLength = LengthSize + textLength + TagSize; + Span dst = output.AllocGetSpan(encodedLength); + + // Encrypt length. + Span length_encrypted = dst.Slice(0, LengthSize); + LengthCipher.ProcessBytes(packet_length, length_encrypted); + + // Encrypt payload. + Span ciphertext = dst.Slice(LengthSize, textLength); + if (pt.IsSingleSegment) + { + PayloadCipher.ProcessBytes(pt.FirstSpan, ciphertext); + } + else + { + foreach (var memory in pt) + { + PayloadCipher.ProcessBytes(memory.Span, ciphertext); + ciphertext = ciphertext.Slice(memory.Length); + } + } + + // Mac. + Span tag = dst.Slice(LengthSize + textLength, TagSize); + Mac.BlockUpdate(dst.Slice(0, LengthSize + textLength)); + Mac.DoFinal(tag); + + output.AppendAlloced(encodedLength); + } +} diff --git a/src/Tmds.Ssh/ECDHKeyExchange.cs b/src/Tmds.Ssh/ECDHKeyExchange.cs index 000993d..fd39323 100644 --- a/src/Tmds.Ssh/ECDHKeyExchange.cs +++ b/src/Tmds.Ssh/ECDHKeyExchange.cs @@ -74,12 +74,12 @@ public async Task TryExchangeAsync(SshConnection connection, } byte[] sessionId = input.ConnectionInfo.SessionId ?? exchangeHash; - byte[] initialIVC2S = Hash(sequencePool, sharedSecret, exchangeHash, (byte)'A', sessionId, input.InitialIVC2SLength); - byte[] initialIVS2C = Hash(sequencePool, sharedSecret, exchangeHash, (byte)'B', sessionId, input.InitialIVS2CLength); - byte[] encryptionKeyC2S = Hash(sequencePool, sharedSecret, exchangeHash, (byte)'C', sessionId, input.EncryptionKeyC2SLength); - byte[] encryptionKeyS2C = Hash(sequencePool, sharedSecret, exchangeHash, (byte)'D', sessionId, input.EncryptionKeyS2CLength); - byte[] integrityKeyC2S = Hash(sequencePool, sharedSecret, exchangeHash, (byte)'E', sessionId, input.IntegrityKeyC2SLength); - byte[] integrityKeyS2C = Hash(sequencePool, sharedSecret, exchangeHash, (byte)'F', sessionId, input.IntegrityKeyS2CLength); + byte[] initialIVC2S = CalculateKey(sequencePool, sharedSecret, exchangeHash, (byte)'A', sessionId, input.InitialIVC2SLength); + byte[] initialIVS2C = CalculateKey(sequencePool, sharedSecret, exchangeHash, (byte)'B', sessionId, input.InitialIVS2CLength); + byte[] encryptionKeyC2S = CalculateKey(sequencePool, sharedSecret, exchangeHash, (byte)'C', sessionId, input.EncryptionKeyC2SLength); + byte[] encryptionKeyS2C = CalculateKey(sequencePool, sharedSecret, exchangeHash, (byte)'D', sessionId, input.EncryptionKeyS2CLength); + byte[] integrityKeyC2S = CalculateKey(sequencePool, sharedSecret, exchangeHash, (byte)'E', sessionId, input.IntegrityKeyC2SLength); + byte[] integrityKeyS2C = CalculateKey(sequencePool, sharedSecret, exchangeHash, (byte)'F', sessionId, input.IntegrityKeyS2CLength); return new KeyExchangeOutput(exchangeHash, initialIVS2C, encryptionKeyS2C, integrityKeyS2C, @@ -117,14 +117,13 @@ private byte[] CalculateExchangeHash(SequencePool sequencePool, SshConnectionInf return hash.GetHashAndReset(); } - private byte[] Hash(SequencePool sequencePool, BigInteger sharedSecret, byte[] exchangeHash, byte c, byte[] sessionId, int hashLength) + private byte[] CalculateKey(SequencePool sequencePool, BigInteger sharedSecret, byte[] exchangeHash, byte c, byte[] sessionId, int keyLength) { // https://tools.ietf.org/html/rfc4253#section-7.2 - byte[] hashRv = new byte[hashLength]; - int hashOffset = 0; + byte[] key = new byte[keyLength]; + int keyOffset = 0; - // TODO: handle 'If the key length needed is longer than the output of the HASH' // HASH(K || H || c || session_id) using Sequence sequence = sequencePool.RentSequence(); var writer = new SequenceWriter(sequence); @@ -139,16 +138,28 @@ private byte[] Hash(SequencePool sequencePool, BigInteger sharedSecret, byte[] e hash.AppendData(segment.Span); } byte[] K1 = hash.GetHashAndReset(); - Append(hashRv, K1, ref hashOffset); + Append(key, K1, ref keyOffset); - while (hashOffset != hashRv.Length) + while (keyOffset != key.Length) { - // TODO: handle 'If the key length needed is longer than the output of the HASH' + sequence.Clear(); + // K3 = HASH(K || H || K1 || K2) - throw new NotSupportedException(); + writer = new SequenceWriter(sequence); + writer.WriteMPInt(sharedSecret); + writer.Write(exchangeHash); + writer.Write(key.AsSpan(0, keyOffset)); + + foreach (var segment in sequence.AsReadOnlySequence()) + { + hash.AppendData(segment.Span); + } + byte[] Kn = hash.GetHashAndReset(); + + Append(key, Kn, ref keyOffset); } - return hashRv; + return key; static void Append(byte[] key, byte[] append, ref int offset) { diff --git a/src/Tmds.Ssh/EncryptionAlgorithm.cs b/src/Tmds.Ssh/EncryptionAlgorithm.cs index e053c65..8450ddf 100644 --- a/src/Tmds.Ssh/EncryptionAlgorithm.cs +++ b/src/Tmds.Ssh/EncryptionAlgorithm.cs @@ -83,5 +83,13 @@ public static EncryptionAlgorithm Find(Name name) => new AesGcmPacketDecoder(sequencePool, key, iv, algorithm.TagLength), isAuthenticated: true, tagLength: 16) }, + { AlgorithmNames.ChaCha20Poly1305, + new EncryptionAlgorithm(keyLength: 512 / 8, ivLength: 0, + (EncryptionAlgorithm algorithm, byte[] key, byte[] iv, HMacAlgorithm? hmac, byte[] hmacKey) + => new ChaCha20Poly1305PacketEncoder(key), + (EncryptionAlgorithm algorithm, SequencePool sequencePool, byte[] key, byte[] iv, HMacAlgorithm? hmac, byte[] hmacKey) + => new ChaCha20Poly1305PacketDecoder(sequencePool, key), + isAuthenticated: true, + tagLength: ChaCha20Poly1305PacketEncoder.TagSize) }, }; } diff --git a/src/Tmds.Ssh/SshChannel.cs b/src/Tmds.Ssh/SshChannel.cs index 54eecb8..8ffb79e 100644 --- a/src/Tmds.Ssh/SshChannel.cs +++ b/src/Tmds.Ssh/SshChannel.cs @@ -190,6 +190,15 @@ public async ValueTask WriteAsync(ReadOnlyMemory memory, CancellationToken int sendWindow = Volatile.Read(ref _sendWindow); if (sendWindow > 0) { + // We need to check the cancellation token in case we send a huge amount of data + // and the peer can keep up (and the send window never becomes zero). + if (cancellationToken.IsCancellationRequested) + { + Cancel(); + + cancellationToken.ThrowIfCancellationRequested(); + } + int toSend = Math.Min(sendWindow, memory.Length); toSend = Math.Min(toSend, SendMaxPacket); if (Interlocked.CompareExchange(ref _sendWindow, sendWindow - toSend, sendWindow) == sendWindow) @@ -213,8 +222,7 @@ public async ValueTask WriteAsync(ReadOnlyMemory memory, CancellationToken { Cancel(); - cancellationToken.ThrowIfCancellationRequested(); - throw CreateCloseException(); + throw; } } } diff --git a/src/Tmds.Ssh/SshClientSettings.Defaults.cs b/src/Tmds.Ssh/SshClientSettings.Defaults.cs index 5dc4dd2..c611e1b 100644 --- a/src/Tmds.Ssh/SshClientSettings.Defaults.cs +++ b/src/Tmds.Ssh/SshClientSettings.Defaults.cs @@ -38,7 +38,7 @@ partial class SshClientSettings private readonly static List EmptyList = []; internal readonly static List SupportedKeyExchangeAlgorithms = [ AlgorithmNames.EcdhSha2Nistp256, AlgorithmNames.EcdhSha2Nistp384, AlgorithmNames.EcdhSha2Nistp521 ]; internal readonly static List SupportedServerHostKeyAlgorithms = [ AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; - internal readonly static List SupportedEncryptionAlgorithms = [ AlgorithmNames.Aes256Gcm, AlgorithmNames.Aes128Gcm ]; + internal readonly static List SupportedEncryptionAlgorithms = [ AlgorithmNames.ChaCha20Poly1305, AlgorithmNames.Aes256Gcm, AlgorithmNames.Aes128Gcm ]; internal readonly static List SupportedPublicKeyAcceptedAlgorithms = [ AlgorithmNames.SshEd25519, AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; internal readonly static List SupportedMacAlgorithms = EmptyList; internal readonly static List SupportedCompressionAlgorithms = [ AlgorithmNames.None ]; diff --git a/test/Tmds.Ssh.Tests/RemoteProcessTests.cs b/test/Tmds.Ssh.Tests/RemoteProcessTests.cs index 167c0f8..fbb8649 100644 --- a/test/Tmds.Ssh.Tests/RemoteProcessTests.cs +++ b/test/Tmds.Ssh.Tests/RemoteProcessTests.cs @@ -223,8 +223,11 @@ public async Task CancelWrite(bool preNotPost) { cts.Cancel(); } - var task = Assert.ThrowsAsync(() => - process.WriteAsync(writeBuffer, cts.Token).AsTask()); + var task = Assert.ThrowsAsync(async () => + { + await Task.Yield(); // make sure we reach '!preNotPost' + await process.WriteAsync(writeBuffer, cts.Token); + }); if (!preNotPost) { cts.Cancel(); diff --git a/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs b/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs index 0c9a9c6..22fab75 100644 --- a/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs +++ b/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs @@ -26,8 +26,8 @@ public void Defaults() Assert.Equal(new[] { new Name("ecdh-sha2-nistp256"), new Name("ecdh-sha2-nistp384"), new Name("ecdh-sha2-nistp521") }, settings.KeyExchangeAlgorithms); Assert.Equal(new[] { new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.ServerHostKeyAlgorithms); Assert.Equal(new[] { new Name("ssh-ed25519"), new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.PublicKeyAcceptedAlgorithms); - Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsClientToServer); - Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsServerToClient); + Assert.Equal(new[] { new Name("chacha20-poly1305@openssh.com"), new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsClientToServer); + Assert.Equal(new[] { new Name("chacha20-poly1305@openssh.com"), new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsServerToClient); Assert.Equal(Array.Empty(), settings.MacAlgorithmsClientToServer); Assert.Equal(Array.Empty(), settings.MacAlgorithmsServerToClient); Assert.Equal(new[] { new Name("none") }, settings.CompressionAlgorithmsClientToServer); diff --git a/test/Tmds.Ssh.Tests/Tmds.Ssh.Tests.csproj b/test/Tmds.Ssh.Tests/Tmds.Ssh.Tests.csproj index 2c730fe..4de8946 100644 --- a/test/Tmds.Ssh.Tests/Tmds.Ssh.Tests.csproj +++ b/test/Tmds.Ssh.Tests/Tmds.Ssh.Tests.csproj @@ -31,4 +31,8 @@ + + + + diff --git a/test/Tmds.Ssh.Tests/xunit.runner.json b/test/Tmds.Ssh.Tests/xunit.runner.json new file mode 100644 index 0000000..a409993 --- /dev/null +++ b/test/Tmds.Ssh.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "diagnosticMessages": true, + "longRunningTestSeconds": 5 +} From 3e414ad8572d5b18414041729103f5402ca8c95f Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Sun, 25 Aug 2024 11:30:35 +0200 Subject: [PATCH 2/5] Add CipherTests. --- test/Tmds.Ssh.Tests/CipherTests.cs | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 test/Tmds.Ssh.Tests/CipherTests.cs diff --git a/test/Tmds.Ssh.Tests/CipherTests.cs b/test/Tmds.Ssh.Tests/CipherTests.cs new file mode 100644 index 0000000..47cb914 --- /dev/null +++ b/test/Tmds.Ssh.Tests/CipherTests.cs @@ -0,0 +1,42 @@ +using System; +using Xunit; + +using System.Threading.Tasks; +using System.Linq; +using System.Collections.Generic; + +namespace Tmds.Ssh.Tests; + +[Collection(nameof(SshServerCollection))] +public class CipherTests +{ + private readonly SshServer _sshServer; + + public CipherTests(SshServer sshServer) + { + _sshServer = sshServer; + } + + [Theory] + [MemberData(nameof(Ciphers))] + public async Task ConnectWithDecryptionCipher(string cipher) + { + using var _ = await _sshServer.CreateClientAsync(SetDecryptionCipher(new Name(cipher))); + } + + [Theory] + [MemberData(nameof(Ciphers))] + public async Task ConnectWithEncryptionCipher(string cipher) + { + using var _ = await _sshServer.CreateClientAsync(SetDecryptionCipher(new Name(cipher))); + } + + public static IEnumerable Ciphers() + => SshClientSettings.SupportedEncryptionAlgorithms.Select(name => new [] { name.ToString() }); + + private Action SetEncryptionCipher(Name cipher) + => (SshClientSettings settings) => { settings.EncryptionAlgorithmsClientToServer = [ cipher ]; }; + + private Action SetDecryptionCipher(Name cipher) + => (SshClientSettings settings) => { settings.EncryptionAlgorithmsServerToClient = [ cipher ]; }; +} From 1ed4e5b5d77d829e17969f71aa2a59f22b610151 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Sun, 25 Aug 2024 11:44:52 +0200 Subject: [PATCH 3/5] Prefer chacha20-poly1305 when there is no AES acceleration. --- src/Tmds.Ssh/SshClientSettings.Defaults.cs | 38 ++++++++++++++++++- test/Tmds.Ssh.Tests/SshClientSettingsTests.cs | 4 +- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/Tmds.Ssh/SshClientSettings.Defaults.cs b/src/Tmds.Ssh/SshClientSettings.Defaults.cs index c611e1b..da7a1e2 100644 --- a/src/Tmds.Ssh/SshClientSettings.Defaults.cs +++ b/src/Tmds.Ssh/SshClientSettings.Defaults.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Security.Cryptography; using static System.Environment; namespace Tmds.Ssh; @@ -38,7 +40,7 @@ partial class SshClientSettings private readonly static List EmptyList = []; internal readonly static List SupportedKeyExchangeAlgorithms = [ AlgorithmNames.EcdhSha2Nistp256, AlgorithmNames.EcdhSha2Nistp384, AlgorithmNames.EcdhSha2Nistp521 ]; internal readonly static List SupportedServerHostKeyAlgorithms = [ AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; - internal readonly static List SupportedEncryptionAlgorithms = [ AlgorithmNames.ChaCha20Poly1305, AlgorithmNames.Aes256Gcm, AlgorithmNames.Aes128Gcm ]; + internal readonly static List SupportedEncryptionAlgorithms = CreatePreferredEncryptionAlgorithms(); internal readonly static List SupportedPublicKeyAcceptedAlgorithms = [ AlgorithmNames.SshEd25519, AlgorithmNames.EcdsaSha2Nistp521, AlgorithmNames.EcdsaSha2Nistp384, AlgorithmNames.EcdsaSha2Nistp256, AlgorithmNames.RsaSshSha2_512, AlgorithmNames.RsaSshSha2_256 ]; internal readonly static List SupportedMacAlgorithms = EmptyList; internal readonly static List SupportedCompressionAlgorithms = [ AlgorithmNames.None ]; @@ -63,6 +65,40 @@ private static IReadOnlyList CreateDefaultCredentials() return credentials.AsReadOnly(); } + private static List CreatePreferredEncryptionAlgorithms() + { + // Prefer AesGcm over ChaCha20Poly when the platform has AES instructions. + bool addAesGcm = AesGcm.IsSupported; + bool hasAesInstructions = System.Runtime.Intrinsics.X86.Aes.X64.IsSupported || + System.Runtime.Intrinsics.X86.Aes.IsSupported || + System.Runtime.Intrinsics.Arm.Aes.IsSupported || + System.Runtime.Intrinsics.Arm.Aes.Arm64.IsSupported; + + List algorithms = new List(); + + if (addAesGcm && hasAesInstructions) + { + AddAesGcmAlgorithms(algorithms); + addAesGcm = false; + } + + algorithms.Add(AlgorithmNames.ChaCha20Poly1305); + + if (addAesGcm) + { + Debug.Assert(!hasAesInstructions); + AddAesGcmAlgorithms(algorithms); + } + + return algorithms; + + static void AddAesGcmAlgorithms(List algorithms) + { + algorithms.Add(AlgorithmNames.Aes256Gcm); + algorithms.Add(AlgorithmNames.Aes128Gcm); + } + } + private static IReadOnlyList CreateDefaultGlobalKnownHostsFilePaths() { string path; diff --git a/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs b/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs index 22fab75..59814c6 100644 --- a/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs +++ b/test/Tmds.Ssh.Tests/SshClientSettingsTests.cs @@ -26,8 +26,8 @@ public void Defaults() Assert.Equal(new[] { new Name("ecdh-sha2-nistp256"), new Name("ecdh-sha2-nistp384"), new Name("ecdh-sha2-nistp521") }, settings.KeyExchangeAlgorithms); Assert.Equal(new[] { new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.ServerHostKeyAlgorithms); Assert.Equal(new[] { new Name("ssh-ed25519"), new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.PublicKeyAcceptedAlgorithms); - Assert.Equal(new[] { new Name("chacha20-poly1305@openssh.com"), new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsClientToServer); - Assert.Equal(new[] { new Name("chacha20-poly1305@openssh.com"), new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsServerToClient); + Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com"), new Name("chacha20-poly1305@openssh.com") }, settings.EncryptionAlgorithmsClientToServer); + Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com"), new Name("chacha20-poly1305@openssh.com") }, settings.EncryptionAlgorithmsServerToClient); Assert.Equal(Array.Empty(), settings.MacAlgorithmsClientToServer); Assert.Equal(Array.Empty(), settings.MacAlgorithmsServerToClient); Assert.Equal(new[] { new Name("none") }, settings.CompressionAlgorithmsClientToServer); From 7a33640eb31d27d4493ed552ac84c958538b710f Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Mon, 26 Aug 2024 20:35:44 +0200 Subject: [PATCH 4/5] Eliminate some per-packet allocations. --- .../ChaCha20Poly1305PacketEncDecBase.cs | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs b/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs index afdcab3..e8cc8cf 100644 --- a/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs +++ b/src/Tmds.Ssh/ChaCha20Poly1305PacketEncDecBase.cs @@ -15,30 +15,46 @@ class ChaCha20Poly1305PacketEncDecBase protected const int PaddTo = 8; // We're not a block cipher. Padd to 8 octets per rfc4253. protected const int LengthSize = 4; // SSH packet length field is 4 bytes. - protected readonly ChaCha7539Engine LengthCipher; - protected readonly ChaCha7539Engine PayloadCipher; + protected readonly MyChaCha20 LengthCipher; + protected readonly MyChaCha20 PayloadCipher; protected readonly Poly1305 Mac; - private readonly byte[] _K1; - private readonly byte[] _K2; + private readonly byte[] _iv; protected ChaCha20Poly1305PacketEncDecBase(byte[] key) { - _K1 = key.AsSpan(32, 32).ToArray(); - _K2 = key.AsSpan(0, 32).ToArray(); - LengthCipher = new(); - PayloadCipher = new(); + _iv = new byte[12]; + byte[] K_1 = key.AsSpan(32, 32).ToArray(); + byte[] K_2 = key.AsSpan(0, 32).ToArray(); + LengthCipher = new(K_1, _iv); + PayloadCipher = new(K_2, _iv); Mac = new(); } protected void ConfigureCiphers(uint sequenceNumber) { - Span iv = stackalloc byte[12]; - Span polyKey = stackalloc byte[64]; - BinaryPrimitives.WriteUInt64BigEndian(iv[4..], sequenceNumber); - LengthCipher.Init(forEncryption: true, new ParametersWithIV(new KeyParameter(_K1), iv)); - PayloadCipher.Init(forEncryption: true, new ParametersWithIV(new KeyParameter(_K2), iv)); + BinaryPrimitives.WriteUInt64BigEndian(_iv.AsSpan(4), sequenceNumber); + LengthCipher.SetIv(_iv); + PayloadCipher.SetIv(_iv); + // note: encrypting 64 bytes increments the ChaCha20 block counter. + Span polyKey = stackalloc byte[64]; PayloadCipher.ProcessBytes(input: polyKey, output: polyKey); Mac.Init(new KeyParameter(polyKey[..32])); } + + // This class eliminates per packet ParametersWithIV/KeyParameter allocations. + sealed protected class MyChaCha20 : ChaCha7539Engine + { + public MyChaCha20(byte[] key, byte[] dummyIv) + { + Init(forEncryption: true, new ParametersWithIV(new KeyParameter(key), dummyIv)); + } + + public void SetIv(byte[] iv) + { + SetKey(null, iv); + + Reset(); + } + } } From 6fb3fbbb917e25ec893b361baf2cd13145247d11 Mon Sep 17 00:00:00 2001 From: Tom Deseyn Date: Tue, 27 Aug 2024 11:31:27 +0200 Subject: [PATCH 5/5] Extend CipherTests. --- README.md | 1 + src/Tmds.Ssh/SshClientSettings.Defaults.cs | 11 +++-- test/Tmds.Ssh.Tests/CipherTests.cs | 57 +++++++++++++++++----- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 816ead8..6d12f7d 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,7 @@ Supported key exchange methods: Supported encryption algorithms: - aes256-gcm@openssh.com - aes128-gcm@openssh.com +- chacha20-poly1305@openssh.com Supported message authentication code algorithms: - none diff --git a/src/Tmds.Ssh/SshClientSettings.Defaults.cs b/src/Tmds.Ssh/SshClientSettings.Defaults.cs index da7a1e2..884ce88 100644 --- a/src/Tmds.Ssh/SshClientSettings.Defaults.cs +++ b/src/Tmds.Ssh/SshClientSettings.Defaults.cs @@ -67,12 +67,15 @@ private static IReadOnlyList CreateDefaultCredentials() private static List CreatePreferredEncryptionAlgorithms() { + // The preferred encryption algorithms must only include algorithms that are considered secure. + // We make an attempt to order them fastest to slowest. + // Prefer AesGcm over ChaCha20Poly when the platform has AES instructions. bool addAesGcm = AesGcm.IsSupported; - bool hasAesInstructions = System.Runtime.Intrinsics.X86.Aes.X64.IsSupported || - System.Runtime.Intrinsics.X86.Aes.IsSupported || - System.Runtime.Intrinsics.Arm.Aes.IsSupported || - System.Runtime.Intrinsics.Arm.Aes.Arm64.IsSupported; + bool hasAesInstructions = System.Runtime.Intrinsics.X86.Aes.X64.IsSupported || + System.Runtime.Intrinsics.X86.Aes.IsSupported || + System.Runtime.Intrinsics.Arm.Aes.IsSupported || + System.Runtime.Intrinsics.Arm.Aes.Arm64.IsSupported; List algorithms = new List(); diff --git a/test/Tmds.Ssh.Tests/CipherTests.cs b/test/Tmds.Ssh.Tests/CipherTests.cs index 47cb914..393a973 100644 --- a/test/Tmds.Ssh.Tests/CipherTests.cs +++ b/test/Tmds.Ssh.Tests/CipherTests.cs @@ -1,9 +1,8 @@ using System; -using Xunit; - -using System.Threading.Tasks; -using System.Linq; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; namespace Tmds.Ssh.Tests; @@ -21,22 +20,56 @@ public CipherTests(SshServer sshServer) [MemberData(nameof(Ciphers))] public async Task ConnectWithDecryptionCipher(string cipher) { - using var _ = await _sshServer.CreateClientAsync(SetDecryptionCipher(new Name(cipher))); + using var _ = await _sshServer.CreateClientAsync( + settings => settings.EncryptionAlgorithmsClientToServer = [ new Name(cipher) ] + ); } [Theory] [MemberData(nameof(Ciphers))] public async Task ConnectWithEncryptionCipher(string cipher) { - using var _ = await _sshServer.CreateClientAsync(SetDecryptionCipher(new Name(cipher))); + using var _ = await _sshServer.CreateClientAsync( + settings => settings.EncryptionAlgorithmsServerToClient = [ new Name(cipher) ] + ); } - public static IEnumerable Ciphers() - => SshClientSettings.SupportedEncryptionAlgorithms.Select(name => new [] { name.ToString() }); + [Theory] + [MemberData(nameof(Ciphers))] + public async Task Padding(string cipher) + { + using var client = await _sshServer.CreateClientAsync( + settings => + { + settings.EncryptionAlgorithmsServerToClient = [ new Name(cipher) ]; + settings.EncryptionAlgorithmsServerToClient = [ new Name(cipher) ]; + } + ); + + using var process = await client.ExecuteAsync("cat"); + + // We increment by one over a range to test various paddings. + foreach (int length in Enumerable.Range(1, 128)) + { + byte[] sendBuffer = new byte[length]; + Random.Shared.NextBytes(sendBuffer); + await process.WriteAsync(sendBuffer); - private Action SetEncryptionCipher(Name cipher) - => (SshClientSettings settings) => { settings.EncryptionAlgorithmsClientToServer = [ cipher ]; }; + byte[] receiveBuffer = new byte[length]; + int receiveBufferOffset = 0; + do + { + Memory dst = receiveBuffer.AsMemory(receiveBufferOffset); + (bool isError, int bytesRead) = await process.ReadAsync(dst, dst); + Assert.False(isError); + Assert.NotEqual(0, bytesRead); + receiveBufferOffset += bytesRead; + } while (receiveBufferOffset != receiveBuffer.Length); - private Action SetDecryptionCipher(Name cipher) - => (SshClientSettings settings) => { settings.EncryptionAlgorithmsServerToClient = [ cipher ]; }; + Assert.Equal(sendBuffer, receiveBuffer); + } + } + + public static IEnumerable Ciphers() + => SshClientSettings.SupportedEncryptionAlgorithms.Select(name => new [] { name.ToString() }); }