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] Add support for PuTTY private key file format (V3 and V2) #1543

Merged
merged 3 commits into from
Dec 2, 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
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
scott-xu marked this conversation as resolved.
Show resolved Hide resolved
_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());
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ED25519Key class does not have a constructor with both publicKeyData and privateKeyData. The current implementation is to generate public key from private key. Should we add a new constructor?

public ED25519Key(byte[] privateKeyData)
{
PrivateKey = new byte[Ed25519.SecretKeySize];
PublicKey = new byte[Ed25519.PublicKeySize];
Buffer.BlockCopy(privateKeyData, 0, PrivateKey, 0, Ed25519.SecretKeySize);
Ed25519.GeneratePublicKey(privateKeyData, 0, PublicKey, 0);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems ok as it is, but I don't mind

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
Loading