Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Private Key] Support more ciphers for OpenSSH private key decryption #1487

Merged
merged 1 commit into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,26 @@ The main types provided by this library are:
* ECDSA 256/384/521 in OpenSSL PEM format ("BEGIN EC PRIVATE KEY")
* ECDSA 256/384/521, ED25519 and RSA in OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")

Private keys can be encrypted using one of the following cipher methods:
Private keys in OpenSSL PEM and ssh.com format can be encrypted using one of the following cipher methods:
* DES-EDE3-CBC
* DES-EDE3-CFB
* DES-CBC
* AES-128-CBC
* AES-192-CBC
* AES-256-CBC

Private keys in OpenSSH key format can be encrypted using one of the following cipher methods:
* 3des-cbc
* aes128-cbc
* aes192-cbc
* aes256-cbc
* aes128-ctr
* aes192-ctr
* aes256-ctr
* aes128-gcm<span></span>@openssh.com
* aes256-gcm<span></span>@openssh.com
* chacha20-poly1305<span></span>@openssh.com

## Host Key Algorithms

**SSH.NET** supports the following host key algorithms:
Expand Down
6 changes: 3 additions & 3 deletions src/Renci.SshNet/ConnectionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -386,9 +386,9 @@ public ConnectionInfo(string host, int port, string username, ProxyTypes proxyTy
{ "aes128-ctr", new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false)) },
{ "aes192-ctr", new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false)) },
{ "aes256-ctr", new CipherInfo(256, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false)) },
{ "aes128-gcm@openssh.com", new CipherInfo(128, (key, iv) => new AesGcmCipher(key, iv), isAead: true) },
{ "aes256-gcm@openssh.com", new CipherInfo(256, (key, iv) => new AesGcmCipher(key, iv), isAead: true) },
{ "chacha20-poly1305@openssh.com", new CipherInfo(512, (key, iv) => new ChaCha20Poly1305Cipher(key), isAead: true) },
{ "aes128-gcm@openssh.com", new CipherInfo(128, (key, iv) => new AesGcmCipher(key, iv, aadLength: 4), isAead: true) },
{ "aes256-gcm@openssh.com", new CipherInfo(256, (key, iv) => new AesGcmCipher(key, iv, aadLength: 4), isAead: true) },
{ "chacha20-poly1305@openssh.com", new CipherInfo(512, (key, iv) => new ChaCha20Poly1305Cipher(key, aadLength: 4), isAead: true) },
{ "aes128-cbc", new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false)) },
{ "aes192-cbc", new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false)) },
{ "aes256-cbc", new CipherInfo(256, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false)) },
Expand Down
119 changes: 100 additions & 19 deletions src/Renci.SshNet/PrivateKeyFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ namespace Renci.SshNet
/// </list>
/// </para>
/// <para>
/// The following encryption algorithms are supported:
/// The following encryption algorithms are supported for OpenSSL PEM and ssh.com format:
/// <list type="bullet">
/// <item>
/// <description>DES-EDE3-CBC</description>
Expand All @@ -60,6 +60,39 @@ namespace Renci.SshNet
/// <description>AES-256-CBC</description>
/// </item>
/// </list>
/// The following encryption algorithms are supported for OpenSSH format:
/// <list type="bullet">
/// <item>
/// <description>3des-cbc</description>
/// </item>
/// <item>
/// <description>aes128-cbc</description>
/// </item>
/// <item>
/// <description>aes192-cbc</description>
/// </item>
/// <item>
/// <description>aes256-cbc</description>
/// </item>
/// <item>
/// <description>aes128-ctr</description>
/// </item>
/// <item>
/// <description>aes192-ctr</description>
/// </item>
/// <item>
/// <description>aes256-ctr</description>
/// </item>
/// <item>
/// <description>aes128-gcm@openssh.com</description>
/// </item>
/// <item>
/// <description>aes256-gcm@openssh.com</description>
/// </item>
/// <item>
/// <description>chacha20-poly1305@openssh.com</description>
/// </item>
/// </list>
/// </para>
/// </remarks>
public partial class PrivateKeyFile : IPrivateKeySource, IDisposable
Expand Down Expand Up @@ -450,7 +483,17 @@ private static byte[] DecryptKey(CipherInfo cipherInfo, byte[] cipherData, strin

var cipher = cipherInfo.Cipher(cipherKey.ToArray(), binarySalt);

return cipher.Decrypt(cipherData);
try
{
return cipher.Decrypt(cipherData);
}
finally
{
if (cipher is IDisposable disposable)
{
disposable.Dispose();
}
}
}

/// <summary>
Expand All @@ -474,7 +517,7 @@ private static Key ParseOpenSshV1Key(byte[] keyFileData, string passPhrase)
throw new SshException("This openssh key does not contain the 'openssh-key-v1' format magic header");
}

// cipher will be "aes256-cbc" if using a passphrase, "none" otherwise
// cipher will be "aes256-cbc" or other cipher if using a passphrase, "none" otherwise
var cipherName = keyReader.ReadString(Encoding.UTF8);

// key derivation function (kdf): bcrypt or nothing
Expand Down Expand Up @@ -503,7 +546,7 @@ private static Key ParseOpenSshV1Key(byte[] keyFileData, string passPhrase)

// possibly encrypted private key
var privateKeyLength = (int)keyReader.ReadUInt32();
var privateKeyBytes = keyReader.ReadBytes(privateKeyLength);
byte[] privateKeyBytes;

// decrypt private key if necessary
if (cipherName != "none")
Expand All @@ -518,38 +561,76 @@ private static Key ParseOpenSshV1Key(byte[] keyFileData, string passPhrase)
throw new SshException("kdf " + kdfName + " is not supported for openssh key file");
}

// inspired by the SSHj library (https://github.com/hierynomus/sshj)
// apply the kdf to derive a key and iv from the passphrase
var passPhraseBytes = Encoding.UTF8.GetBytes(passPhrase);
var keyiv = new byte[48];
new BCrypt().Pbkdf(passPhraseBytes, salt, rounds, keyiv);
var key = new byte[32];
Array.Copy(keyiv, 0, key, 0, 32);
var iv = new byte[16];
Array.Copy(keyiv, 32, iv, 0, 16);

AesCipher cipher;
var ivLength = 16;
CipherInfo cipherInfo;
switch (cipherName)
{
case "3des-cbc":
ivLength = 8;
cipherInfo = new CipherInfo(192, (key, iv) => new TripleDesCipher(key, new CbcCipherMode(iv), padding: null));
break;
case "aes128-cbc":
cipherInfo = new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false));
break;
case "aes192-cbc":
cipherInfo = new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false));
break;
case "aes256-cbc":
cipher = new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false);
cipherInfo = new CipherInfo(256, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false));
break;
case "aes128-ctr":
cipherInfo = new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false));
break;
case "aes192-ctr":
cipherInfo = new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false));
break;
case "aes256-ctr":
cipher = new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false);
cipherInfo = new CipherInfo(256, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false));
break;
case "aes128-gcm@openssh.com":
cipherInfo = new CipherInfo(128, (key, iv) => new AesGcmCipher(key, iv, aadLength: 0), isAead: true);
break;
case "aes256-gcm@openssh.com":
cipherInfo = new CipherInfo(256, (key, iv) => new AesGcmCipher(key, iv, aadLength: 0), isAead: true);
break;
case "chacha20-poly1305@openssh.com":
ivLength = 12;
cipherInfo = new CipherInfo(256, (key, iv) => new ChaCha20Poly1305Cipher(key, aadLength: 0), isAead: true);
break;
default:
throw new SshException("Cipher '" + cipherName + "' is not supported for an OpenSSH key.");
}

var keyLength = cipherInfo.KeySize / 8;

// inspired by the SSHj library (https://github.com/hierynomus/sshj)
// apply the kdf to derive a key and iv from the passphrase
var passPhraseBytes = Encoding.UTF8.GetBytes(passPhrase);
var keyiv = new byte[keyLength + ivLength];
new BCrypt().Pbkdf(passPhraseBytes, salt, rounds, keyiv);

var key = keyiv.Take(keyLength);
var iv = keyiv.Take(keyLength, ivLength);

var cipher = cipherInfo.Cipher(key, iv);
var cipherData = keyReader.ReadBytes(privateKeyLength + cipher.TagSize);
scott-xu marked this conversation as resolved.
Show resolved Hide resolved

try
{
privateKeyBytes = cipher.Decrypt(privateKeyBytes);
privateKeyBytes = cipher.Decrypt(cipherData, 0, privateKeyLength);
}
finally
{
cipher.Dispose();
if (cipher is IDisposable disposable)
{
disposable.Dispose();
}
}
}
else
{
privateKeyBytes = keyReader.ReadBytes(privateKeyLength);
}

// validate private key length
privateKeyLength = privateKeyBytes.Length;
Expand Down
34 changes: 16 additions & 18 deletions src/Renci.SshNet/Security/Cryptography/Ciphers/AesGcmCipher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ namespace Renci.SshNet.Security.Cryptography.Ciphers
/// </summary>
internal sealed partial class AesGcmCipher : SymmetricCipher, IDisposable
{
private const int PacketLengthFieldLength = 4;
private const int TagSizeInBytes = 16;
private readonly byte[] _iv;
private readonly int _aadLength;
#if NET6_0_OR_GREATER
private readonly Impl _impl;
#else
Expand Down Expand Up @@ -55,11 +55,13 @@ public override int TagSize
/// </summary>
/// <param name="key">The key.</param>
/// <param name="iv">The IV.</param>
public AesGcmCipher(byte[] key, byte[] iv)
/// <param name="aadLength">The length of additional associated data.</param>
public AesGcmCipher(byte[] key, byte[] iv, int aadLength)
: base(key)
{
// SSH AES-GCM requires a 12-octet Initial IV
_iv = iv.Take(12);
_aadLength = aadLength;
#if NET6_0_OR_GREATER
if (System.Security.Cryptography.AesGcm.IsSupported)
{
Expand All @@ -78,32 +80,30 @@ public AesGcmCipher(byte[] key, byte[] iv)
/// <param name="input">
/// The input data with below format:
/// <code>
/// [outbound sequence field][packet length field][padding length field sz][payload][random paddings]
/// [----4 bytes----(offset)][------4 bytes------][----------------Plain Text---------------(length)]
/// [----(offset)][----AAD----][----Plain Text----(length)]
/// </code>
/// </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:
/// <code>
/// [packet length field][padding length field sz][payload][random paddings][Authenticated TAG]
/// [------4 bytes------][------------------Cipher Text--------------------][-------TAG-------]
/// [----AAD----][----Cipher Text----][----TAG----]
/// </code>
/// </returns>
public override byte[] Encrypt(byte[] input, int offset, int length)
{
var output = new byte[length + TagSize];
Buffer.BlockCopy(input, offset, output, 0, PacketLengthFieldLength);
Buffer.BlockCopy(input, offset, output, 0, _aadLength);

_impl.Encrypt(
input,
plainTextOffset: offset + PacketLengthFieldLength,
plainTextLength: length - PacketLengthFieldLength,
plainTextOffset: offset + _aadLength,
plainTextLength: length - _aadLength,
associatedDataOffset: offset,
associatedDataLength: PacketLengthFieldLength,
associatedDataLength: _aadLength,
output,
cipherTextOffset: PacketLengthFieldLength);
cipherTextOffset: _aadLength);

IncrementCounter();

Expand All @@ -116,31 +116,29 @@ public override byte[] Encrypt(byte[] input, int offset, int length)
/// <param name="input">
/// The input data with below format:
/// <code>
/// [inbound sequence field][packet length field][padding length field sz][payload][random paddings][Authenticated TAG]
/// [--------4 bytes-------][--4 bytes--(offset)][--------------Cipher Text----------------(length)][-------TAG-------]
/// [----][----AAD----(offset)][----Cipher Text----(length)][----TAG----]
/// </code>
/// </param>
/// <param name="offset">The zero-based offset in <paramref name="input"/> at which to begin decrypting and authenticating.</param>
/// <param name="length">The number of bytes to decrypt and authenticate from <paramref name="input"/>.</param>
/// <returns>
/// The decrypted data with below format:
/// <code>
/// [padding length field sz][payload][random paddings]
/// [--------------------Plain Text-------------------]
/// [----Plain Text----]
/// </code>
/// </returns>
public override byte[] Decrypt(byte[] input, int offset, int length)
{
Debug.Assert(offset == 8, "The offset must be 8");
Debug.Assert(offset >= _aadLength, "The offset must be greater than or equals to aad length");

var output = new byte[length];

_impl.Decrypt(
input,
cipherTextOffset: offset,
cipherTextLength: length,
associatedDataOffset: offset - PacketLengthFieldLength,
associatedDataLength: PacketLengthFieldLength,
associatedDataOffset: offset - _aadLength,
associatedDataLength: _aadLength,
output,
plainTextOffset: 0);

Expand Down
Loading