Skip to content

Commit

Permalink
[Private Key] Add support for PuTTY private key file format (V3 and V…
Browse files Browse the repository at this point in the history
…2) (#1543)

* [Private Key] Add support for PuTTY private key

* add negative test for mac

---------

Co-authored-by: Rob Hague <[email protected]>
  • Loading branch information
scott-xu and Rob-Hague authored Dec 2, 2024
1 parent 70c1246 commit 3b4f2cf
Show file tree
Hide file tree
Showing 20 changed files with 678 additions and 15 deletions.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.5.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="coverlet.msbuild" Version="6.0.2" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1">
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,21 @@ The main types provided by this library are:
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
* ssh.com format ("BEGIN SSH2 ENCRYPTED PRIVATE KEY")
* OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
* DSA in
* OpenSSL traditional PEM format ("BEGIN DSA PRIVATE KEY")
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
* ssh.com format ("BEGIN SSH2 ENCRYPTED PRIVATE KEY")
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
* ECDSA 256/384/521 in
* OpenSSL traditional PEM format ("BEGIN EC PRIVATE KEY")
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
* OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
* ED25519 in
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
* OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")

Private keys in OpenSSL traditional PEM format can be encrypted using one of the following cipher methods:
* DES-EDE3-CBC
Expand All @@ -124,7 +128,7 @@ Private keys in OpenSSL traditional PEM format can be encrypted using one of the

Private keys in OpenSSL PKCS#8 PEM format can be encrypted using any cipher method BouncyCastle supports.

Private keys in ssh.com format can be encrypted using one of the following cipher methods:
Private keys in ssh.com format can be encrypted using the following cipher method:
* 3des-cbc

Private keys in OpenSSH key format can be encrypted using one of the following cipher methods:
Expand All @@ -139,6 +143,9 @@ Private keys in OpenSSH key format can be encrypted using one of the following c
* aes256-gcm<span></span>@openssh.com
* chacha20-poly1305<span></span>@openssh.com

Private keys in PuTTY private key format can be encrypted using the following cipher method:
* aes256-cbc

## Host Key Algorithms

**SSH.NET** supports the following host key algorithms:
Expand Down
12 changes: 5 additions & 7 deletions src/Renci.SshNet/PrivateKeyFile.PKCS1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,11 @@ public Key Parse()
{
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
}

var binarySalt = new byte[_salt.Length / 2];
for (var i = 0; i < binarySalt.Length; i++)
{
binarySalt[i] = Convert.ToByte(_salt.Substring(i * 2, 2), 16);
}

#if NET
var binarySalt = Convert.FromHexString(_salt);
#else
var binarySalt = Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_salt);
#endif
CipherInfo cipher;
switch (_cipherName)
{
Expand Down
273 changes: 273 additions & 0 deletions src/Renci.SshNet/PrivateKeyFile.PuTTY.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;

using Renci.SshNet.Abstractions;
using Renci.SshNet.Common;
using Renci.SshNet.Security;
using Renci.SshNet.Security.Cryptography.Ciphers;

namespace Renci.SshNet
{
public partial class PrivateKeyFile
{
private sealed class PuTTY : IPrivateKeyParser
{
private readonly string _version;
private readonly string _algorithmName;
private readonly string _encryptionType;
private readonly string _comment;
private readonly byte[] _publicKey;
private readonly string? _argon2Type;
private readonly string? _argon2Salt;
private readonly string? _argon2Iterations;
private readonly string? _argon2Memory;
private readonly string? _argon2Parallelism;
private readonly byte[] _data;
private readonly string _mac;
private readonly string? _passPhrase;

public PuTTY(string version, string algorithmName, string encryptionType, string comment, byte[] publicKey, string? argon2Type, string? argon2Salt, string? argon2Iterations, string? argon2Memory, string? argon2Parallelism, byte[] data, string mac, string? passPhrase)
{
_version = version;
_algorithmName = algorithmName;
_encryptionType = encryptionType;
_comment = comment;
_publicKey = publicKey;
_argon2Type = argon2Type;
_argon2Salt = argon2Salt;
_argon2Iterations = argon2Iterations;
_argon2Memory = argon2Memory;
_argon2Parallelism = argon2Parallelism;
_data = data;
_mac = mac;
_passPhrase = passPhrase;
}

/// <summary>
/// Parses an PuTTY PPK key file.
/// <see href="https://tartarus.org/~simon/putty-snapshots/htmldoc/AppendixC.html"/>.
/// </summary>
public Key Parse()
{
byte[] privateKey;
HMAC hmac;
switch (_encryptionType)
{
case "aes256-cbc":
if (string.IsNullOrEmpty(_passPhrase))
{
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
}

byte[] cipherKey;
byte[] cipherIV;
switch (_version)
{
case "3":
ThrowHelper.ThrowIfNullOrEmpty(_argon2Type);
ThrowHelper.ThrowIfNullOrEmpty(_argon2Iterations);
ThrowHelper.ThrowIfNullOrEmpty(_argon2Memory);
ThrowHelper.ThrowIfNullOrEmpty(_argon2Parallelism);
ThrowHelper.ThrowIfNullOrEmpty(_argon2Salt);

var keyData = Argon2(
_argon2Type,
Convert.ToInt32(_argon2Iterations),
Convert.ToInt32(_argon2Memory),
Convert.ToInt32(_argon2Parallelism),
#if NET
Convert.FromHexString(_argon2Salt),
#else
Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_argon2Salt),
#endif
_passPhrase);

cipherKey = keyData.Take(32);
cipherIV = keyData.Take(32, 16);

var macKey = keyData.Take(48, 32);
hmac = new HMACSHA256(macKey);

break;
case "2":
keyData = V2KDF(_passPhrase);

cipherKey = keyData.Take(32);
cipherIV = new byte[16];

macKey = CryptoAbstraction.HashSHA1(Encoding.UTF8.GetBytes("putty-private-key-file-mac-key" + _passPhrase)).Take(20);
hmac = new HMACSHA1(macKey);

break;
default:
throw new SshException("PuTTY key file version " + _version + " is not supported");
}

using (var cipher = new AesCipher(cipherKey, cipherIV, AesCipherMode.CBC, pkcs7Padding: false))
{
privateKey = cipher.Decrypt(_data);
}

break;
case "none":
switch (_version)
{
case "3":
hmac = new HMACSHA256(Array.Empty<byte>());
break;
case "2":
var macKey = CryptoAbstraction.HashSHA1(Encoding.UTF8.GetBytes("putty-private-key-file-mac-key"));
hmac = new HMACSHA1(macKey);
break;
default:
throw new SshException("PuTTY key file version " + _version + " is not supported");
}

privateKey = _data;
break;
default:
throw new SshException("Encryption " + _encryptionType + " is not supported for PuTTY key file");
}

byte[] macData;
using (var macStream = new SshDataStream(256))
{
macStream.Write(_algorithmName, Encoding.UTF8);
macStream.Write(_encryptionType, Encoding.UTF8);
macStream.Write(_comment, Encoding.UTF8);
macStream.WriteBinary(_publicKey);
macStream.WriteBinary(privateKey);
macData = macStream.ToArray();
}

byte[] macValue;
using (hmac)
{
macValue = hmac.ComputeHash(macData);
}
#if NET
var reference = Convert.FromHexString(_mac);
#else
var reference = Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_mac);
#endif
if (!macValue.SequenceEqual(reference))
{
throw new SshException("MAC verification failed for PuTTY key file");
}

var publicKeyReader = new SshDataReader(_publicKey);
var keyType = publicKeyReader.ReadString(Encoding.UTF8);
Debug.Assert(keyType == _algorithmName, $"{nameof(keyType)} is not the same as {nameof(_algorithmName)}");

var privateKeyReader = new SshDataReader(privateKey);

Key parsedKey;

switch (keyType)
{
case "ssh-ed25519":
parsedKey = new ED25519Key(privateKeyReader.ReadBignum2());
break;
case "ecdsa-sha2-nistp256":
case "ecdsa-sha2-nistp384":
case "ecdsa-sha2-nistp521":
var curve = publicKeyReader.ReadString(Encoding.ASCII);
var pub = publicKeyReader.ReadBignum2();
var prv = privateKeyReader.ReadBignum2();
parsedKey = new EcdsaKey(curve, pub, prv);
break;
case "ssh-dss":
var p = publicKeyReader.ReadBignum();
var q = publicKeyReader.ReadBignum();
var g = publicKeyReader.ReadBignum();
var y = publicKeyReader.ReadBignum();
var x = privateKeyReader.ReadBignum();
parsedKey = new DsaKey(p, q, g, y, x);
break;
case "ssh-rsa":
var exponent = publicKeyReader.ReadBignum(); // e
var modulus = publicKeyReader.ReadBignum(); // n
var d = privateKeyReader.ReadBignum(); // d
p = privateKeyReader.ReadBignum(); // p
q = privateKeyReader.ReadBignum(); // q
var inverseQ = privateKeyReader.ReadBignum(); // iqmp
parsedKey = new RsaKey(modulus, exponent, d, p, q, inverseQ);
break;
default:
throw new SshException("Key type " + keyType + " is not supported for PuTTY key file");
}

parsedKey.Comment = _comment;
return parsedKey;
}

private static byte[] Argon2(string type, int iterations, int memory, int parallelism, byte[] salt, string passPhrase)
{
int param;
switch (type)
{
case "Argon2i":
param = Argon2Parameters.Argon2i;
break;
case "Argon2d":
param = Argon2Parameters.Argon2d;
break;
case "Argon2id":
param = Argon2Parameters.Argon2id;
break;
default:
throw new SshException("KDF " + type + " is not supported for PuTTY key file");
}

var a2p = new Argon2Parameters.Builder(param)
.WithVersion(Argon2Parameters.Version13)
.WithIterations(iterations)
.WithMemoryAsKB(memory)
.WithParallelism(parallelism)
.WithSalt(salt).Build();

var generator = new Argon2BytesGenerator();

generator.Init(a2p);

var output = new byte[80];
var bytes = generator.GenerateBytes(passPhrase.ToCharArray(), output);

if (bytes != output.Length)
{
throw new SshException("Failed to generate key via Argon2");
}

return output;
}

private static byte[] V2KDF(string passPhrase)
{
var cipherKey = new List<byte>();

var passPhraseBytes = Encoding.UTF8.GetBytes(passPhrase);
for (var sequenceNumber = 0; sequenceNumber < 2; sequenceNumber++)
{
using (var sha1 = SHA1.Create())
{
var sequence = new byte[] { 0, 0, 0, (byte)sequenceNumber };
_ = sha1.TransformBlock(sequence, 0, 4, outputBuffer: null, 0);
_ = sha1.TransformFinalBlock(passPhraseBytes, 0, passPhraseBytes.Length);
Debug.Assert(sha1.Hash != null, "Hash is null");
cipherKey.AddRange(sha1.Hash);
}
}

return cipherKey.ToArray();
}
}
}
}
Loading

0 comments on commit 3b4f2cf

Please sign in to comment.