Generating PuTTY key files from C#

A while back, I had a project which needed to be able to write PuTTY private key files. (For context, it was a tool that was able to spin up Amazon EC2 instances, then launch PuTTY to connect to them, so it needed to turn an RSA key into a private key which could be given to PuTTY). Now, there’s a tool called PuTTYgen which is able to do this, but I needed I needed to be able to do it programmatically.

However, you won’t find the PuTTY key format documented anywhere, and noone else seems to have attempted this. Thankfully PuTTY’s source is available, which made life a bit easier, but a lot of trial-and-error was still required!

The resulting key contains both public and private parts of the RSA key, but is not protected with a passphrase: that’s left as an exercise to the reader.

The basic structure is:

  1. Header, containing the key type, encryption type, and comment
  2. Public key header, containing the number of lines in the public key
  3. Base64-encoded string containing the public key parameters (exponent and modulus), wrapped to 64 columns
  4. Private key header, containing the number of lines in the private key
  5. Base64-encoded string containing the private key parameters (D, P, Q, inverse Q), wrapped to 64 columns
  6. MAC of key type, encryption type, public key parameters, and private key parameters

All of the key parameters are written as length-prefixed numbers: a 4-byte length prefixes the actual bytes in the number. In some cases, a leading NULL is added to the number and included in the length: I’m not sure why this is the case.

Anyway, without further ado:

public class PuttyKeyFileGenerator
{
    private const int prefixSize = 4;
    private const int paddedPrefixSize = prefixSize + 1;
    private const int lineLength = 64;
    private const string keyType = "ssh-rsa";
    private const string encryptionType = "none";

    public static string RSAToPuttyPrivateKey(RSACryptoServiceProvider cryptoServiceProvider, string comment = "imported-key")
    {
        RSAParameters keyParameters = cryptoServiceProvider.ExportParameters(includePrivateParameters: true);

        byte[] publicBuffer = new byte[3 + keyType.Length + paddedPrefixSize + keyParameters.Exponent.Length +
                                       paddedPrefixSize + keyParameters.Modulus.Length + 1];

        using (var writer = new BinaryWriter(new MemoryStream(publicBuffer)))
        {
            writer.Write(new byte[] { 0x00, 0x00, 0x00 });
            writer.Write(keyType);
            WritePrefixed(writer, keyParameters.Exponent, true);
            WritePrefixed(writer, keyParameters.Modulus, true);
        }
        string publicBlob = Convert.ToBase64String(publicBuffer);

        byte[] privateBuffer = new byte[paddedPrefixSize + keyParameters.D.Length + paddedPrefixSize + keyParameters.P.Length +
                                        paddedPrefixSize + keyParameters.Q.Length + paddedPrefixSize + keyParameters.InverseQ.Length];

        using (var writer = new BinaryWriter(new MemoryStream(privateBuffer)))
        {
            WritePrefixed(writer, keyParameters.D, true);
            WritePrefixed(writer, keyParameters.P, true);
            WritePrefixed(writer, keyParameters.Q, true);
            WritePrefixed(writer, keyParameters.InverseQ, true);
        }
        string privateBlob = Convert.ToBase64String(privateBuffer);

        byte[] bytesToHash = new byte[prefixSize + keyType.Length + prefixSize + encryptionType.Length + prefixSize + comment.Length +
                                      prefixSize + publicBuffer.Length + prefixSize + privateBuffer.Length];

        using (var writer = new BinaryWriter(new MemoryStream(bytesToHash)))
        {
            WritePrefixed(writer, Encoding.ASCII.GetBytes(keyType));
            WritePrefixed(writer, Encoding.ASCII.GetBytes(encryptionType));
            WritePrefixed(writer, Encoding.ASCII.GetBytes(comment));
            WritePrefixed(writer, publicBuffer);
            WritePrefixed(writer, privateBuffer);
        }

        var hmacsha1 = new HMACSHA1(new SHA1CryptoServiceProvider().ComputeHash(Encoding.ASCII.GetBytes("putty-private-key-file-mac-key")));
        string hash = String.Join("", hmacsha1.ComputeHash(bytesToHash).Select(x => String.Format("{0:x2}", x)));

        var sb = new StringBuilder();
        sb.AppendLine("PuTTY-User-Key-File-2: " + keyType);
        sb.AppendLine("Encryption: " + encryptionType);
        sb.AppendLine("Comment: " + comment);

        var publicLines = SpliceText(publicBlob, lineLength).ToArray();
        sb.AppendLine("Public-Lines: " + publicLines.Length);
        foreach (var line in publicLines)
        {
            sb.AppendLine(line);
        }

        var privateLines = SpliceText(privateBlob, lineLength).ToArray();
        sb.AppendLine("Private-Lines: " + privateLines.Length);
        foreach (var line in privateLines)
        {
            sb.AppendLine(line);
        }

        sb.AppendLine("Private-MAC: " + hash);

        return sb.ToString();
    }

    private static void WritePrefixed(BinaryWriter writer, byte[] bytes, bool addLeadingNull = false)
    {
        var length = bytes.Length;
        if (addLeadingNull)
            length++;

        writer.Write(BitConverter.GetBytes(length).Reverse().ToArray());
        if (addLeadingNull)
            writer.Write((byte)0x00);
        writer.Write(bytes);
    }

    private static IEnumerable<string> SpliceText(string text, int length)
    {
        for (int i = 0; i < text.Length; i += length)
        {
            yield return text.Substring(i, Math.Min(length, text.Length - i));
        }
    }
}