Skip to content

Add support for PuTTY ppk format private key files #468

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

Closed
wants to merge 4 commits into from
Closed
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
219 changes: 210 additions & 9 deletions src/Renci.SshNet/PrivateKeyFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Renci.SshNet.Security.Cryptography.Ciphers.Modes;
using Renci.SshNet.Security.Cryptography.Ciphers.Paddings;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Renci.SshNet.Security.Cryptography;

namespace Renci.SshNet
Expand All @@ -26,10 +27,10 @@ namespace Renci.SshNet
/// The following private keys are supported:
/// <list type="bullet">
/// <item>
/// <description>RSA in OpenSSL PEM, ssh.com and OpenSSH key format</description>
/// <description>RSA in OpenSSL PEM, ssh.com, OpenSSH, and PuTTY PPK key format</description>
/// </item>
/// <item>
/// <description>DSA in OpenSSL PEM and ssh.com format</description>
/// <description>DSA in OpenSSL PEM, ssh.com, and PuTTY PPK format</description>
/// </item>
/// <item>
/// <description>ECDSA 256/384/521 in OpenSSL PEM and OpenSSH key format</description>
Expand Down Expand Up @@ -65,7 +66,21 @@ namespace Renci.SshNet
/// </remarks>
public class PrivateKeyFile : IDisposable
{
private static readonly Regex PrivateKeyRegex = new Regex(@"^-+ *BEGIN (?<keyName>\w+( \w+)*) PRIVATE KEY *-+\r?\n((Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\r?\n\r?\n)|(Comment: ""?[^\r\n]*""?\r?\n))?(?<data>([a-zA-Z0-9/+=]{1,80}\r?\n)+)-+ *END \k<keyName> PRIVATE KEY *-+",
private static readonly Regex SshPrivateKeyRegex = new Regex(@"^-+ *BEGIN (?<keyName>\w+( \w+)*) PRIVATE KEY *-+\r?\n((Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[A-F0-9]+)\r?\n\r?\n)|(Comment: ""?[^\r\n]*""?\r?\n))?(?<data>([a-zA-Z0-9/+=]{1,80}\r?\n)+)-+ *END \k<keyName> PRIVATE KEY *-+",
#if FEATURE_REGEX_COMPILE
RegexOptions.Compiled | RegexOptions.Multiline);
#else
RegexOptions.Multiline);
#endif
private static readonly Regex PuttyPrivateKeyRegex = new Regex(
@"^PuTTY-User-Key-File-(?<fileVersion>[0-9]+): *(?<keyAlgo>[^\r\n]+)(\r|\n)+" +
@"Encryption: *(?<cipherName>[^\r\n]+)(\r|\n)+" +
@"Comment: *(?<keyName>[^\r\n]+)(\r|\n)+" +
@"Public-Lines: *(?<publicLines>[0-9]+)(\r|\n)+" +
@"(?<publicData>([a-zA-Z0-9/+=]{1,80}(\r|\n)+)+)" +
@"Private-Lines: *(?<privateLines>[0-9]+)(\r|\n)+" +
@"(?<privateData>([a-zA-Z0-9/+=]{1,80}(\r|\n)+)+)" +
@"Private-(?<macOrHash>(MAC|Hash)): *(?<hashData>[a-zA-Z0-9/+=]+)",
#if FEATURE_REGEX_COMPILE
RegexOptions.Compiled | RegexOptions.Multiline);
#else
Expand Down Expand Up @@ -133,25 +148,37 @@ public PrivateKeyFile(Stream privateKey, string passPhrase)
/// </summary>
/// <param name="privateKey">The private key.</param>
/// <param name="passPhrase">The pass phrase.</param>
[SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope", Justification = "this._key disposed in Dispose(bool) method.")]
private void Open(Stream privateKey, string passPhrase)
{
if (privateKey == null)
throw new ArgumentNullException("privateKey");

Match privateKeyMatch;

string text;
using (var sr = new StreamReader(privateKey))
text = sr.ReadToEnd();

privateKeyMatch = SshPrivateKeyRegex.Match(text);
if (privateKeyMatch.Success)
{
var text = sr.ReadToEnd();
privateKeyMatch = PrivateKeyRegex.Match(text);
SshOpen(passPhrase, privateKeyMatch);
return;
}

if (!privateKeyMatch.Success)
privateKeyMatch = PuttyPrivateKeyRegex.Match(text);
if (privateKeyMatch.Success)
{
throw new SshException("Invalid private key file.");
PuttyOpen(passPhrase, privateKeyMatch);
return;
}

throw new SshException("Invalid private key file.");
}

[SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope", Justification = "this._key disposed in Dispose(bool) method.")]
private void SshOpen(string passPhrase, Match privateKeyMatch)
{
var keyName = privateKeyMatch.Result("${keyName}");
var cipherName = privateKeyMatch.Result("${cipherName}");
var salt = privateKeyMatch.Result("${salt}");
Expand Down Expand Up @@ -262,7 +289,7 @@ private void Open(Stream privateKey, string passPhrase)

if (decryptedLength > blobSize - 4)
throw new SshException("Invalid passphrase.");

if (keyType == "if-modn{sign{rsa-pkcs1-sha1},encrypt{rsa-pkcs1v2-oaep}}")
{
var exponent = reader.ReadBigIntWithBits();//e
Expand Down Expand Up @@ -299,6 +326,165 @@ private void Open(Stream privateKey, string passPhrase)
}
}

[SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope", Justification = "this._key disposed in Dispose(bool) method.")]
private void PuttyOpen(string passPhrase, Match privateKeyMatch)
{
var fileVersion = Convert.ToInt32(privateKeyMatch.Result("${fileVersion}"));
var keyAlgo = privateKeyMatch.Result("${keyAlgo}");
var cipherName = privateKeyMatch.Result("${cipherName}");
var keyName = privateKeyMatch.Result("${keyName}");
var publicLines = Convert.ToInt32(privateKeyMatch.Result("${publicLines}"));
var publicData = privateKeyMatch.Result("${publicData}");
var privateLines = Convert.ToInt32(privateKeyMatch.Result("${privateLines}"));
var privateData = privateKeyMatch.Result("${privateData}");
var macOrHash = privateKeyMatch.Result("${macOrHash}");
var hashData = privateKeyMatch.Result("${hashData}");

if (fileVersion != 1 && fileVersion != 2)
throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, "PuTTY private key file version {0} not supported.", fileVersion));

var publicDataBinary = Convert.FromBase64String(publicData);
var privateDataBinary = Convert.FromBase64String(privateData);

if (string.IsNullOrEmpty(cipherName))
throw new SshPassPhraseNullOrEmptyException("PuTTY private key file cipher name is invalid");

byte[] privateDataPlaintext;
if (cipherName == "none")
{
// Don't use a passphrase for unencrypted keys
passPhrase = "";
privateDataPlaintext = privateDataBinary;
}
else
{
if (string.IsNullOrEmpty(passPhrase))
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");

if (cipherName != "aes256-cbc")
throw new SshPassPhraseNullOrEmptyException(string.Format(CultureInfo.CurrentCulture, "Passphrase cipher '{0}' not supported.", cipherName));

CipherInfo cipherInfo = new CipherInfo(256, (key, iv) => new AesCipher(key, new CbcCipherMode(iv), new PKCS7Padding()));
if (privateDataBinary.Length % 16 != 0)
throw new SshPassPhraseNullOrEmptyException("Private key data not multiple of cipher block size.");

var cipherKey = GetPuttyCipherKey(passPhrase, cipherInfo.KeySize / 8);
var cipher = cipherInfo.Cipher(cipherKey, new byte[cipherKey.Length]);

privateDataPlaintext = cipher.Decrypt(privateDataBinary);
}

byte[] macData;
if (fileVersion == 1)
{
// In old version, MAC/Hash only includes the private key
macData = privateDataPlaintext;
}
else
{
using (var data = new SshDataStream(0))
{
data.Write(keyAlgo, Encoding.UTF8);
data.Write(cipherName, Encoding.UTF8);
data.Write(keyName, Encoding.UTF8);
data.WriteBinary(publicDataBinary);
data.WriteBinary(privateDataPlaintext);
macData = data.ToArray();
}
}

byte[] macOrHashResult;
if (macOrHash == "MAC")
{
using (var sha1 = CryptoAbstraction.CreateSHA1())
{
byte[] macKey = sha1.ComputeHash(Encoding.UTF8.GetBytes("putty-private-key-file-mac-key" + passPhrase));
using (var hmac = CryptoAbstraction.CreateHMACSHA1(macKey))
{
macOrHashResult = hmac.ComputeHash(macData);
}
}
}
else if (macOrHash == "Hash" && fileVersion == 1)
{
using (var sha1 = CryptoAbstraction.CreateSHA1())
{
macOrHashResult = sha1.ComputeHash(macData);
}
}
else
{
throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, "Private key verification algorithm {0} not supported for file version {1}", macOrHash, fileVersion));
}

if (!String.Equals(ConvertByteArrayToHex(macOrHashResult), hashData, StringComparison.OrdinalIgnoreCase))
{
throw new SshException("Invalid private key");
}

var reader = new SshDataReader(publicDataBinary);
var publicKeyAlgo = reader.ReadString(Encoding.UTF8);
if (publicKeyAlgo != keyAlgo)
{
throw new SshException(string.Format(CultureInfo.CurrentCulture, "Public key algorithm specified as {0}, expecting {1}", publicKeyAlgo, keyAlgo));
}
if (keyAlgo == "ssh-rsa")
{
var exponent = reader.ReadBigIntWithBytes();
var modulus = reader.ReadBigIntWithBytes();
reader = new SshDataReader(privateDataPlaintext);
var d = reader.ReadBigIntWithBytes();
var p = reader.ReadBigIntWithBytes();
var q = reader.ReadBigIntWithBytes();
var inverseQ = reader.ReadBigIntWithBytes();
_key = new RsaKey(modulus, exponent, d, p, q, inverseQ);
HostKey = new KeyHostAlgorithm("ssh-rsa", _key);
}
else if (keyAlgo == "ssh-dss")
{
var p = reader.ReadBigIntWithBytes();
var q = reader.ReadBigIntWithBytes();
var g = reader.ReadBigIntWithBytes();
var y = reader.ReadBigIntWithBytes();
reader = new SshDataReader(privateDataPlaintext);
var x = reader.ReadBigIntWithBytes();
_key = new DsaKey(p, q, g, y, x);
HostKey = new KeyHostAlgorithm("ssh-dss", _key);
}
else
{
throw new SshException(string.Format(CultureInfo.CurrentCulture, "Unsupported key algorithm {0}", keyAlgo));
}
}

private static string ConvertByteArrayToHex(byte[] bytes)
{
return bytes.Aggregate(new StringBuilder(bytes.Length * 2), (sb, b) => sb.Append(b.ToString("X2"))).ToString();
}

private static byte[] GetPuttyCipherKey(string passphrase, int length)
{
var cipherKey = new List<byte>();

using (var sha1 = CryptoAbstraction.CreateSHA1())
{
var passphraseBytes = Encoding.UTF8.GetBytes(passphrase);

int counter = 0;
do {
var counterBytes = BitConverter.GetBytes(counter++);

if (BitConverter.IsLittleEndian)
Array.Reverse(counterBytes);

var hash = sha1.ComputeHash(counterBytes.Concat(passphraseBytes).ToArray());
cipherKey.AddRange(hash);
} while (cipherKey.Count < length);
}

return cipherKey.Take(length).ToArray();
}

private static byte[] GetCipherKey(string passphrase, int length)
{
var cipherKey = new List<byte>();
Expand Down Expand Up @@ -605,6 +791,21 @@ public SshDataReader(byte[] data)
return base.ReadBytes();
}

/// <summary>
/// Reads next mpint data type from internal buffer where length specified in bytes.
/// </summary>
/// <returns>mpint read.</returns>
public BigInteger ReadBigIntWithBytes()
{
var length = (int)base.ReadUInt32();

var data = base.ReadBytes(length);
var bytesArray = new byte[data.Length + 1];
Buffer.BlockCopy(data, 0, bytesArray, 1, data.Length);

return new BigInteger(bytesArray.Reverse().ToArray());
}

/// <summary>
/// Reads next mpint data type from internal buffer where length specified in bits.
/// </summary>
Expand Down