Encrypt packets in-place (#1787)

Support in-place encryption in the cipher types, then use it on the plaintext packets
instead of allocating a new array each time. Removes 2 of 4 bytes allocated for each
byte uploaded over SFTP.

For AES-CTR, supporting in-place encryption in this case means adding a persistent buffer
for the keystream and encrypting in chunks. The performance difference is ~1-2% i.e.
marginal versus one-shotting it. The variance is similar also for different choices of
buffer size (here 4096 is used).
This commit is contained in:
Rob Hague
2026-05-05 12:57:13 +02:00
committed by GitHub
parent 45d8631266
commit 8ed8d38fa8
12 changed files with 971 additions and 105 deletions
+26
View File
@@ -11,6 +11,7 @@ using System.Net;
using System.Net.Sockets;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
using Renci.SshNet.Messages;
@@ -434,5 +435,30 @@ namespace Renci.SshNet.Common
}
}
#endif
public static bool TryComputeHash(
this HashAlgorithm hashAlgorithm,
byte[] buffer,
int offset,
int count,
Span<byte> destination,
out int bytesWritten)
{
#if NET
return hashAlgorithm.TryComputeHash(buffer.AsSpan(offset, count), destination, out bytesWritten);
#else
if (destination.Length < hashAlgorithm.HashSize / 8)
{
bytesWritten = 0;
return false;
}
var hash = hashAlgorithm.ComputeHash(buffer, offset, count);
hash.CopyTo(destination);
bytesWritten = hash.Length;
return true;
#endif
}
}
}
+23 -13
View File
@@ -1,6 +1,7 @@
using System.IO;
using System.Security.Cryptography;
#nullable enable
using System.IO;
using Renci.SshNet.Abstractions;
using Renci.SshNet.Common;
using Renci.SshNet.Compression;
@@ -37,7 +38,8 @@ namespace Renci.SshNet.Messages
base.WriteBytes(stream);
}
internal byte[] GetPacket(byte paddingMultiplier, Compressor compressor, bool excludePacketLengthFieldWhenPadding = false)
/// <returns>[4 bytes] || packet_len || padding_len || payload || padding || [macLength bytes].</returns>
internal byte[] GetPacket(byte paddingMultiplier, Compressor? compressor, bool excludePacketLengthFieldWhenPadding = false, int macLength = 0)
{
const int outboundPacketSequenceSize = 4;
@@ -82,10 +84,6 @@ namespace Renci.SshNet.Messages
// padding length calculation
var paddingLength = GetPaddingLength(paddingMultiplier, excludePacketLengthFieldWhenPadding ? packetLength - 4 : packetLength);
// add padding bytes
var paddingBytes = RandomNumberGenerator.GetBytes(paddingLength);
sshDataStream.Write(paddingBytes, 0, paddingLength);
var packetDataLength = GetPacketDataLength(messageLength, paddingLength);
// skip bytes for outbound packet sequence
@@ -97,7 +95,16 @@ namespace Renci.SshNet.Messages
// add packet padding length
sshDataStream.WriteByte(paddingLength);
return sshDataStream.ToArray();
_ = sshDataStream.Seek(0, SeekOrigin.End);
sshDataStream.SetLength(sshDataStream.Length + paddingLength + macLength);
var buffer = sshDataStream.ToArray();
// add padding bytes
CryptoAbstraction.Randomizer.GetBytes(buffer, (int)sshDataStream.Position, paddingLength);
return buffer;
}
}
else
@@ -112,7 +119,7 @@ namespace Renci.SshNet.Messages
var packetDataLength = GetPacketDataLength(messageLength, paddingLength);
// lets construct an SSH data stream of the exact size required
using (var sshDataStream = new SshDataStream(packetLength + paddingLength + outboundPacketSequenceSize))
using (var sshDataStream = new SshDataStream(packetLength + paddingLength + outboundPacketSequenceSize + macLength))
{
// skip bytes for outbound packet sequenceSize
_ = sshDataStream.Seek(outboundPacketSequenceSize, SeekOrigin.Begin);
@@ -126,11 +133,14 @@ namespace Renci.SshNet.Messages
// add message payload
WriteBytes(sshDataStream);
// add padding bytes
var paddingBytes = RandomNumberGenerator.GetBytes(paddingLength);
sshDataStream.Write(paddingBytes, 0, paddingLength);
sshDataStream.SetLength(sshDataStream.Length + paddingLength + macLength);
return sshDataStream.ToArray();
var buffer = sshDataStream.ToArray();
// add padding bytes
CryptoAbstraction.Randomizer.GetBytes(buffer, (int)sshDataStream.Position, paddingLength);
return buffer;
}
}
}
@@ -54,6 +54,26 @@ namespace Renci.SshNet.Security.Cryptography
/// </returns>
public abstract byte[] Encrypt(byte[] input, int offset, int length);
/// <summary>
/// Encrypts the specified input into a given buffer.
/// </summary>
/// <param name="input">The input.</param>
/// <param name="offset">The zero-based offset in <paramref name="input"/> at which to begin encrypting.</param>
/// <param name="length">The number of bytes to encrypt from <paramref name="input"/>.</param>
/// <param name="output">The output buffer to write to.</param>
/// <param name="outputOffset">The zero-based offset in <paramref name="output"/> at which to write encrypted output.</param>
/// <returns>
/// The number of bytes written to <paramref name="output"/>.
/// </returns>
public virtual int Encrypt(byte[] input, int offset, int length, byte[] output, int outputOffset)
{
var ciphertext = Encrypt(input, offset, length);
ciphertext.AsSpan().CopyTo(output.AsSpan(outputOffset));
return ciphertext.Length;
}
/// <summary>
/// Decrypts the specified input.
/// </summary>
@@ -1,5 +1,6 @@
#nullable enable
using System;
using System.Diagnostics;
using System.Security.Cryptography;
using Renci.SshNet.Common;
@@ -44,6 +45,13 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
return Transform(_encryptor, input, offset, length, output: null, 0, out _);
}
public override int Encrypt(byte[] input, int offset, int length, byte[] output, int outputOffset)
{
_ = Transform(_encryptor, input, offset, length, output, outputOffset, out var bytesWritten);
return bytesWritten;
}
public override byte[] Decrypt(byte[] input, int offset, int length)
{
return Transform(_decryptor, input, offset, length, output: null, 0, out _);
@@ -80,6 +88,8 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
// encrypted data in all packets are considered a single data
// stream i.e. we do not want to reset the state between calls to Decrypt.
byte[]? tmp = null;
var paddingLength = 0;
if (length % BlockSize > 0)
{
@@ -89,34 +99,29 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
// See https://github.com/dotnet/runtime/blob/e7d837da5b1aacd9325a8b8f2214cfaf4d3f0ff6/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/SymmetricPadding.cs#L20-L21
paddingLength = BlockSize - (length % BlockSize);
var tmp = new byte[length + paddingLength];
tmp = new byte[length + paddingLength];
input.AsSpan(offset, length).CopyTo(tmp);
input = tmp;
offset = 0;
length = tmp.Length;
}
}
if (output is null)
output ??= new byte[length];
if (tmp is not null)
{
output = new byte[length];
bytesWritten = transform.TransformBlock(tmp, 0, tmp.Length, tmp, 0);
bytesWritten = transform.TransformBlock(input, offset, length, output, outputOffset);
bytesWritten -= paddingLength;
// Manually unpad the output.
Array.Resize(ref output, bytesWritten);
tmp.AsSpan(0, length).CopyTo(output.AsSpan(outputOffset));
}
else
{
bytesWritten = transform.TransformBlock(input, offset, length, output, outputOffset);
bytesWritten -= paddingLength;
}
bytesWritten -= paddingLength;
Debug.Assert(bytesWritten == length);
return output;
}
@@ -11,6 +11,8 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
{
private sealed class CtrImpl : BlockCipher, IDisposable
{
private const int KeystreamBufferLength = 4096;
private readonly Aes _aes;
private readonly ICryptoTransform _encryptor;
@@ -18,6 +20,8 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
private ulong _ivUpper; // The upper 64 bits of the IV
private ulong _ivLower; // The lower 64 bits of the IV
private byte[]? _keystreamBuffer;
public CtrImpl(
byte[] key,
byte[] iv)
@@ -39,6 +43,11 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
return Decrypt(input, offset, length);
}
public override int Encrypt(byte[] input, int offset, int length, byte[] output, int outputOffset)
{
return Decrypt(input, offset, length, output, outputOffset);
}
public override byte[] Decrypt(byte[] input, int offset, int length)
{
ArgumentNullException.ThrowIfNull(input);
@@ -84,23 +93,61 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
Debug.Assert(blockSizedLength % BlockSize == 0);
if (output is null)
byte[] keystream;
int keystreamOffset;
int chunkSize;
if (data == output && offset == outputOffset)
{
output = new byte[blockSizedLength];
outputOffset = 0;
keystream = _keystreamBuffer ??= new byte[KeystreamBufferLength];
keystreamOffset = 0;
chunkSize = KeystreamBufferLength;
}
else if (data.AsSpan(offset, length).Overlaps(output.AsSpan(outputOffset, blockSizedLength)))
else
{
throw new ArgumentException("Input and output buffers must not overlap");
if (output is null)
{
output = new byte[blockSizedLength];
outputOffset = 0;
}
else if (data.AsSpan(offset, length).Overlaps(output.AsSpan(outputOffset, blockSizedLength)))
{
throw new ArgumentException("Input and output buffers must not overlap (except when identical).");
}
keystream = output;
keystreamOffset = outputOffset;
chunkSize = length;
}
CTRCreateCounterArray(output.AsSpan(outputOffset, blockSizedLength));
var bytesProcessed = 0;
while (bytesProcessed < length)
{
var bytesThisChunk = Math.Min(chunkSize, length - bytesProcessed);
var blockSizedChunk = (bytesThisChunk + BlockSize - 1) & ~(BlockSize - 1);
var bytesWritten = _encryptor.TransformBlock(output, outputOffset, blockSizedLength, output, outputOffset);
CTRCreateCounterArray(keystream.AsSpan(keystreamOffset, blockSizedChunk));
Debug.Assert(bytesWritten == blockSizedLength);
var bytesWritten = _encryptor.TransformBlock(
inputBuffer: keystream,
inputOffset: keystreamOffset,
inputCount: blockSizedChunk,
outputBuffer: keystream,
outputOffset: keystreamOffset);
ArrayXOR(output, outputOffset, data, offset, length);
Debug.Assert(bytesWritten == blockSizedChunk);
ArrayXOR(
dst: output,
dstOffset: outputOffset + bytesProcessed,
a: data,
aOffset: offset + bytesProcessed,
b: keystream,
bOffset: keystreamOffset,
length: bytesThisChunk);
bytesProcessed += bytesThisChunk;
}
return output;
}
@@ -120,21 +167,21 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
}
}
// XOR 2 arrays using Vector<byte>
private static void ArrayXOR(byte[] buffer, int bufferOffset, byte[] data, int offset, int length)
// dst[i] = a[i] ^ b[i]
private static void ArrayXOR(byte[] dst, int dstOffset, byte[] a, int aOffset, byte[] b, int bOffset, int length)
{
var i = 0;
var oneVectorFromEnd = length - Vector<byte>.Count;
for (; i <= oneVectorFromEnd; i += Vector<byte>.Count)
{
var v = new Vector<byte>(buffer, bufferOffset + i) ^ new Vector<byte>(data, offset + i);
v.CopyTo(buffer, bufferOffset + i);
var v = new Vector<byte>(a, aOffset + i) ^ new Vector<byte>(b, bOffset + i);
v.CopyTo(dst, dstOffset + i);
}
for (; i < length; i++)
{
buffer[bufferOffset + i] ^= data[offset + i];
dst[dstOffset + i] = (byte)(a[aOffset + i] ^ b[bOffset + i]);
}
}
@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Security.Cryptography;
@@ -71,6 +72,12 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
return _impl.Encrypt(input, offset, length);
}
/// <inheritdoc/>
public override int Encrypt(byte[] input, int offset, int length, byte[] output, int outputOffset)
{
return _impl.Encrypt(input, offset, length, output, outputOffset);
}
/// <inheritdoc/>
public override byte[] Decrypt(byte[] input, int offset, int length)
{
@@ -1,4 +1,5 @@
using System;
#nullable enable
using System;
using System.Buffers.Binary;
using System.Diagnostics;
@@ -74,6 +75,17 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
}
}
public override byte[] Encrypt(byte[] input, int offset, int length)
{
var output = new byte[length + TagSizeInBytes];
var bytesWritten = Encrypt(input, offset, length, output, 0);
Debug.Assert(bytesWritten == output.Length);
return output;
}
/// <summary>
/// Encrypts the specified input.
/// </summary>
@@ -85,16 +97,17 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
/// </param>
/// <param name="offset">The zero-based offset in <paramref name="input"/> at which to begin encrypting.</param>
/// <param name="length">The number of bytes to encrypt from <paramref name="input"/>.</param>
/// <returns>
/// The encrypted data with below format:
/// <param name="output">The output buffer to write to.</param>
/// <param name="outputOffset">The zero-based offset in <paramref name="output"/> at which to write encrypted output.</param>
/// <remarks>
/// The output data is written with the below format:
/// <code>
/// [----AAD----][----Cipher Text----][----TAG----]
/// </code>
/// </returns>
public override byte[] Encrypt(byte[] input, int offset, int length)
/// </remarks>
public override int Encrypt(byte[] input, int offset, int length, byte[] output, int outputOffset)
{
var output = new byte[length + TagSize];
Buffer.BlockCopy(input, offset, output, 0, _aadLength);
input.AsSpan(offset, _aadLength).CopyTo(output.AsSpan(outputOffset));
_impl.Encrypt(
input,
@@ -103,11 +116,11 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
associatedDataOffset: offset,
associatedDataLength: _aadLength,
output,
cipherTextOffset: _aadLength);
outputOffset + _aadLength);
IncrementCounter();
return output;
return length + TagSizeInBytes;
}
public override byte[] Decrypt(byte[] input, int offset, int length)
@@ -1,4 +1,5 @@
using System;
#nullable enable
using System;
using System.Buffers.Binary;
using System.Diagnostics;
@@ -20,9 +21,9 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
{
private readonly byte[] _iv;
private readonly int _aadLength;
private readonly KeyParameter _aadKeyParameter;
private readonly KeyParameter? _aadKeyParameter;
private readonly KeyParameter _keyParameter;
private readonly ChaCha7539Engine _aadCipher;
private readonly ChaCha7539Engine? _aadCipher;
private readonly ChaCha7539Engine _cipher;
private readonly Poly1305 _mac;
@@ -74,6 +75,17 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
_mac = new Poly1305();
}
public override byte[] Encrypt(byte[] input, int offset, int length)
{
var output = new byte[length + TagSize];
var bytesWritten = Encrypt(input, offset, length, output, 0);
Debug.Assert(bytesWritten == length);
return output;
}
/// <summary>
/// Encrypts the specified input.
/// </summary>
@@ -85,13 +97,15 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
/// </param>
/// <param name="offset">The zero-based offset in <paramref name="input"/> at which to begin encrypting.</param>
/// <param name="length">The number of bytes to encrypt from <paramref name="input"/>.</param>
/// <returns>
/// The encrypted data with below format:
/// <param name="output">The output buffer to write to.</param>
/// <param name="outputOffset">The zero-based offset in <paramref name="output"/> at which to write encrypted output.</param>
/// <remarks>
/// The output data is written with the below format:
/// <code>
/// [----Cipher AAD----][----Cipher Text----][----TAG----]
/// </code>
/// </returns>
public override byte[] Encrypt(byte[] input, int offset, int length)
/// </remarks>
public override int Encrypt(byte[] input, int offset, int length, byte[] output, int outputOffset)
{
_aadCipher?.Init(forEncryption: true, new ParametersWithIV(_aadKeyParameter, _iv));
_cipher.Init(forEncryption: true, new ParametersWithIV(_keyParameter, _iv));
@@ -100,15 +114,15 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
_cipher.ProcessBytes(keyStream, 0, keyStream.Length, keyStream, 0);
_mac.Init(new KeyParameter(keyStream, 0, 32));
var output = new byte[length + TagSize];
_aadCipher?.ProcessBytes(input, offset, _aadLength, output, outputOffset);
_cipher.ProcessBytes(input, offset + _aadLength, length - _aadLength, output, outputOffset + _aadLength);
_aadCipher?.ProcessBytes(input, offset, _aadLength, output, 0);
_cipher.ProcessBytes(input, offset + _aadLength, length - _aadLength, output, _aadLength);
_mac.BlockUpdate(output, outputOffset, length);
var macBytesWritten = _mac.DoFinal(output, outputOffset + length);
_mac.BlockUpdate(output, 0, length);
_ = _mac.DoFinal(output, length);
Debug.Assert(macBytesWritten == TagSize);
return output;
return length + macBytesWritten;
}
/// <summary>
+50 -38
View File
@@ -1050,67 +1050,79 @@ namespace Renci.SshNet
}
var paddingMultiplier = _clientCipher is null ? (byte)8 : Math.Max((byte)8, _clientCipher.MinimumSize);
var packetData = message.GetPacket(paddingMultiplier, _clientCompression, _clientEtm || _clientAead);
var macLength = 0;
if (_clientAead)
{
macLength = _clientCipher.TagSize;
}
else if (_clientMac != null)
{
macLength = _clientMac.HashSize / 8;
}
var packetData = message.GetPacket(paddingMultiplier, _clientCompression, _clientEtm || _clientAead, macLength);
if (packetData.Length > MaximumSshPacketSize)
{
throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "Packet is too big. Maximum packet size is {0} bytes.", MaximumSshPacketSize));
}
// take a write lock to ensure the outbound packet sequence number is incremented
// atomically, and only after the packet has actually been sent
lock (_socketWriteLock)
{
byte[] hash = null;
var packetDataOffset = 4; // first four bytes are reserved for outbound packet sequence
// write outbound packet sequence to start of packet data
BinaryPrimitives.WriteUInt32BigEndian(packetData, _outboundPacketSequence);
if (_clientMac != null && !_clientEtm)
{
// calculate packet hash
hash = _clientMac.ComputeHash(packetData);
// non-ETM mac = MAC(key, sequence_number || unencrypted_packet)
var hashSuccess = _clientMac.TryComputeHash(
buffer: packetData,
offset: 0,
count: packetData.Length - macLength,
destination: packetData.AsSpan(packetData.Length - macLength),
bytesWritten: out var bytesWritten);
Debug.Assert(hashSuccess && bytesWritten == macLength);
}
// Encrypt packet data
if (_clientCipher != null)
{
_clientCipher.SetSequenceNumber(_outboundPacketSequence);
if (_clientEtm)
{
// The length of the "packet length" field in bytes
const int packetLengthFieldLength = 4;
var encryptedData = _clientCipher.Encrypt(packetData, packetDataOffset + packetLengthFieldLength, packetData.Length - packetDataOffset - packetLengthFieldLength);
// Not encrypting the sequence number (it is not part of the packet),
// nor the packet length for ETM.
var offset = _clientEtm ? 8 : 4;
Array.Resize(ref packetData, packetDataOffset + packetLengthFieldLength + encryptedData.Length);
var numberOfBytesEncrypted = _clientCipher.Encrypt(
input: packetData,
offset,
length: packetData.Length - offset - macLength,
output: packetData,
outputOffset: offset);
// write encrypted data
Buffer.BlockCopy(encryptedData, 0, packetData, packetDataOffset + packetLengthFieldLength, encryptedData.Length);
// calculate packet hash
hash = _clientMac.ComputeHash(packetData);
}
else
{
packetData = _clientCipher.Encrypt(packetData, packetDataOffset, packetData.Length - packetDataOffset);
packetDataOffset = 0;
}
Debug.Assert(numberOfBytesEncrypted == packetData.Length - offset - macLength + (_clientAead ? macLength : 0));
}
if (packetData.Length > MaximumSshPacketSize)
if (_clientMac != null && _clientEtm)
{
throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "Packet is too big. Maximum packet size is {0} bytes.", MaximumSshPacketSize));
// ETM mac = MAC(key, sequence_number || packet_length || encrypted_packet)
var hashSuccess = _clientMac.TryComputeHash(
buffer: packetData,
offset: 0,
count: packetData.Length - macLength,
destination: packetData.AsSpan(packetData.Length - macLength),
bytesWritten: out var bytesWritten);
Debug.Assert(hashSuccess && bytesWritten == macLength);
}
var packetLength = packetData.Length - packetDataOffset;
if (hash is null)
{
SendPacket(packetData, packetDataOffset, packetLength);
}
else
{
var data = new byte[packetLength + hash.Length];
Buffer.BlockCopy(packetData, packetDataOffset, data, 0, packetLength);
Buffer.BlockCopy(hash, 0, data, packetLength, hash.Length);
SendPacket(data, 0, data.Length);
}
SendPacket(packetData, 4, packetData.Length - 4);
if (_isStrictKex && message is NewKeysMessage)
{
@@ -62,13 +62,13 @@ namespace Renci.SshNet.Benchmarks.Security.Cryptography.Ciphers
[Benchmark]
public byte[] Encrypt_ECB()
{
return new AesCipher(_key, null, AesCipherMode.ECB, false).Encrypt(_data);
return new AesCipher(_key, [], AesCipherMode.ECB, false).Encrypt(_data);
}
[Benchmark]
public byte[] Decrypt_ECB()
{
return new AesCipher(_key, null, AesCipherMode.ECB, false).Decrypt(_data);
return new AesCipher(_key, [], AesCipherMode.ECB, false).Decrypt(_data);
}
}
}
@@ -141,6 +141,20 @@ foreach ((string mode, (string modeCode, CipherMode? bclMode)) in modes)
tw.WriteLine();
tw.WriteLine($"CollectionAssert.AreEqual(input, decrypted);");
tw.WriteLine();
tw.WriteLine($"// Decrypt in-place");
tw.WriteLine($"var decryptCount = new AesCipher(key, {modeCode}, pkcs7Padding: {(pad ? "true" : "false")}).Decrypt(actual, 0, actual.Length, actual, 0);");
tw.WriteLine();
tw.WriteLine($"Assert.AreEqual(input.Length, decryptCount);");
tw.WriteLine($"CollectionAssert.AreEqual(input, {(pad ? "actual.Take(decryptCount)" : "actual")});");
tw.WriteLine();
tw.WriteLine($"// Re-encrypt in-place");
tw.WriteLine($"var encryptCount = new AesCipher(key, {modeCode}, pkcs7Padding: {(pad ? "true" : "false")}).Encrypt(actual, 0, input.Length, actual, 0);");
tw.WriteLine();
tw.WriteLine($"Assert.AreEqual(expected.Length, encryptCount);");
tw.WriteLine($"CollectionAssert.AreEqual(expected, actual);");
tw.Indent--;
tw.WriteLine("}");
tw.WriteLine();
File diff suppressed because it is too large Load Diff