Skip to content

Commit 05547da

Browse files
committed
[Private Key] Add support for PuTTY private key
1 parent f50fdcc commit 05547da

20 files changed

+598
-15
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
</PropertyGroup>
66
<ItemGroup>
77
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
8-
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.4.0" />
8+
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.5.0" />
99
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
1010
<PackageVersion Include="coverlet.msbuild" Version="6.0.2" />
1111
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1">

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,21 @@ The main types provided by this library are:
101101
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
102102
* ssh.com format ("BEGIN SSH2 ENCRYPTED PRIVATE KEY")
103103
* OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")
104+
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
104105
* DSA in
105106
* OpenSSL traditional PEM format ("BEGIN DSA PRIVATE KEY")
106107
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
107108
* ssh.com format ("BEGIN SSH2 ENCRYPTED PRIVATE KEY")
109+
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
108110
* ECDSA 256/384/521 in
109111
* OpenSSL traditional PEM format ("BEGIN EC PRIVATE KEY")
110112
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
111113
* OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")
114+
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
112115
* ED25519 in
113116
* OpenSSL PKCS#8 PEM format ("BEGIN PRIVATE KEY", "BEGIN ENCRYPTED PRIVATE KEY")
114117
* OpenSSH key format ("BEGIN OPENSSH PRIVATE KEY")
118+
* PuTTY private key format ("PuTTY-User-Key-File-2", "PuTTY-User-Key-File-3")
115119

116120
Private keys in OpenSSL traditional PEM format can be encrypted using one of the following cipher methods:
117121
* DES-EDE3-CBC
@@ -123,7 +127,7 @@ Private keys in OpenSSL traditional PEM format can be encrypted using one of the
123127

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

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

129133
Private keys in OpenSSH key format can be encrypted using one of the following cipher methods:
@@ -138,6 +142,9 @@ Private keys in OpenSSH key format can be encrypted using one of the following c
138142
* aes256-gcm<span></span>@openssh.com
139143
* chacha20-poly1305<span></span>@openssh.com
140144

145+
Private keys in PuTTY private key format can be encrypted using the following cipher method:
146+
* aes256-cbc
147+
141148
## Host Key Algorithms
142149

143150
**SSH.NET** supports the following host key algorithms:

src/Renci.SshNet/PrivateKeyFile.PKCS1.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,11 @@ public Key Parse()
4242
{
4343
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
4444
}
45-
46-
var binarySalt = new byte[_salt.Length / 2];
47-
for (var i = 0; i < binarySalt.Length; i++)
48-
{
49-
binarySalt[i] = Convert.ToByte(_salt.Substring(i * 2, 2), 16);
50-
}
51-
45+
#if NET
46+
var binarySalt = Convert.FromHexString(_salt);
47+
#else
48+
var binarySalt = Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_salt);
49+
#endif
5250
CipherInfo cipher;
5351
switch (_cipherName)
5452
{
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
#nullable enable
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Diagnostics;
5+
using System.Linq;
6+
using System.Security.Cryptography;
7+
using System.Text;
8+
9+
using Org.BouncyCastle.Crypto.Generators;
10+
using Org.BouncyCastle.Crypto.Parameters;
11+
12+
using Renci.SshNet.Abstractions;
13+
using Renci.SshNet.Common;
14+
using Renci.SshNet.Security;
15+
using Renci.SshNet.Security.Cryptography.Ciphers;
16+
17+
namespace Renci.SshNet
18+
{
19+
public partial class PrivateKeyFile
20+
{
21+
private sealed class PuTTY : IPrivateKeyParser
22+
{
23+
private readonly string _version;
24+
private readonly string _algorithmName;
25+
private readonly string _encryptionType;
26+
private readonly string _comment;
27+
private readonly byte[] _publicKey;
28+
private readonly string? _argon2Type;
29+
private readonly string? _argon2Salt;
30+
private readonly string? _argon2Iterations;
31+
private readonly string? _argon2Memory;
32+
private readonly string? _argon2Parallelism;
33+
private readonly byte[] _data;
34+
private readonly string _mac;
35+
private readonly string? _passPhrase;
36+
37+
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)
38+
{
39+
_version = version;
40+
_algorithmName = algorithmName;
41+
_encryptionType = encryptionType;
42+
_comment = comment;
43+
_publicKey = publicKey;
44+
_argon2Type = argon2Type;
45+
_argon2Salt = argon2Salt;
46+
_argon2Iterations = argon2Iterations;
47+
_argon2Memory = argon2Memory;
48+
_argon2Parallelism = argon2Parallelism;
49+
_data = data;
50+
_mac = mac;
51+
_passPhrase = passPhrase;
52+
}
53+
54+
/// <summary>
55+
/// Parses an PuTTY PPK key file.
56+
/// <see href="https://tartarus.org/~simon/putty-snapshots/htmldoc/AppendixC.html"/>.
57+
/// </summary>
58+
public Key Parse()
59+
{
60+
byte[] privateKey;
61+
HMAC hmac;
62+
switch (_encryptionType)
63+
{
64+
case "aes256-cbc":
65+
if (string.IsNullOrEmpty(_passPhrase))
66+
{
67+
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
68+
}
69+
70+
byte[] cipherKey;
71+
byte[] cipherIV;
72+
switch (_version)
73+
{
74+
case "3":
75+
ThrowHelper.ThrowIfNullOrEmpty(_argon2Type);
76+
ThrowHelper.ThrowIfNullOrEmpty(_argon2Iterations);
77+
ThrowHelper.ThrowIfNullOrEmpty(_argon2Memory);
78+
ThrowHelper.ThrowIfNullOrEmpty(_argon2Parallelism);
79+
ThrowHelper.ThrowIfNullOrEmpty(_argon2Salt);
80+
81+
var keyData = Argon2(
82+
_argon2Type,
83+
Convert.ToInt32(_argon2Iterations),
84+
Convert.ToInt32(_argon2Memory),
85+
Convert.ToInt32(_argon2Parallelism),
86+
#if NET
87+
Convert.FromHexString(_argon2Salt),
88+
#else
89+
Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_argon2Salt),
90+
#endif
91+
_passPhrase);
92+
93+
cipherKey = keyData.Take(32);
94+
cipherIV = keyData.Take(32, 16);
95+
96+
var macKey = keyData.Take(48, 32);
97+
hmac = new HMACSHA256(macKey);
98+
99+
break;
100+
case "2":
101+
keyData = V2KDF(_passPhrase);
102+
103+
cipherKey = keyData.Take(32);
104+
cipherIV = new byte[16];
105+
106+
macKey = CryptoAbstraction.HashSHA1(Encoding.UTF8.GetBytes("putty-private-key-file-mac-key" + _passPhrase)).Take(20);
107+
hmac = new HMACSHA1(macKey);
108+
109+
break;
110+
default:
111+
throw new SshException("PuTTY key file version " + _version + " is not supported");
112+
}
113+
114+
using (var cipher = new AesCipher(cipherKey, cipherIV, AesCipherMode.CBC, pkcs7Padding: false))
115+
{
116+
privateKey = cipher.Decrypt(_data);
117+
}
118+
119+
break;
120+
case "none":
121+
switch (_version)
122+
{
123+
case "3":
124+
hmac = new HMACSHA256(Array.Empty<byte>());
125+
break;
126+
case "2":
127+
var macKey = CryptoAbstraction.HashSHA1(Encoding.UTF8.GetBytes("putty-private-key-file-mac-key"));
128+
hmac = new HMACSHA1(macKey);
129+
break;
130+
default:
131+
throw new SshException("PuTTY key file version " + _version + " is not supported");
132+
}
133+
134+
privateKey = _data;
135+
break;
136+
default:
137+
throw new SshException("Encryption " + _encryptionType + " is not supported for PuTTY key file");
138+
}
139+
140+
byte[] macData;
141+
using (var macStream = new SshDataStream(256))
142+
{
143+
macStream.Write(_algorithmName, Encoding.UTF8);
144+
macStream.Write(_encryptionType, Encoding.UTF8);
145+
macStream.Write(_comment, Encoding.UTF8);
146+
macStream.WriteBinary(_publicKey);
147+
macStream.WriteBinary(privateKey);
148+
macData = macStream.ToArray();
149+
}
150+
151+
byte[] macValue;
152+
using (hmac)
153+
{
154+
macValue = hmac.ComputeHash(macData);
155+
}
156+
#if NET
157+
var reference = Convert.FromHexString(_mac);
158+
#else
159+
var reference = Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_mac);
160+
#endif
161+
if (!macValue.SequenceEqual(reference))
162+
{
163+
throw new SshException("MAC verification failed for PuTTY key file");
164+
}
165+
166+
var publicKeyReader = new SshDataReader(_publicKey);
167+
var keyType = publicKeyReader.ReadString(Encoding.UTF8);
168+
Debug.Assert(keyType == _algorithmName, $"{nameof(keyType)} is not the same as {nameof(_algorithmName)}");
169+
170+
var privateKeyReader = new SshDataReader(privateKey);
171+
172+
Key parsedKey;
173+
174+
switch (keyType)
175+
{
176+
case "ssh-ed25519":
177+
parsedKey = new ED25519Key(privateKeyReader.ReadBignum2());
178+
break;
179+
case "ecdsa-sha2-nistp256":
180+
case "ecdsa-sha2-nistp384":
181+
case "ecdsa-sha2-nistp521":
182+
var curve = publicKeyReader.ReadString(Encoding.ASCII);
183+
var pub = publicKeyReader.ReadBignum2();
184+
var prv = privateKeyReader.ReadBignum2();
185+
parsedKey = new EcdsaKey(curve, pub, prv);
186+
break;
187+
case "ssh-dss":
188+
var p = publicKeyReader.ReadBignum();
189+
var q = publicKeyReader.ReadBignum();
190+
var g = publicKeyReader.ReadBignum();
191+
var y = publicKeyReader.ReadBignum();
192+
var x = privateKeyReader.ReadBignum();
193+
parsedKey = new DsaKey(p, q, g, y, x);
194+
break;
195+
case "ssh-rsa":
196+
var exponent = publicKeyReader.ReadBignum(); // e
197+
var modulus = publicKeyReader.ReadBignum(); // n
198+
var d = privateKeyReader.ReadBignum(); // d
199+
p = privateKeyReader.ReadBignum(); // p
200+
q = privateKeyReader.ReadBignum(); // q
201+
var inverseQ = privateKeyReader.ReadBignum(); // iqmp
202+
parsedKey = new RsaKey(modulus, exponent, d, p, q, inverseQ);
203+
break;
204+
default:
205+
throw new SshException("Key type " + keyType + " is not supported for PuTTY key file");
206+
}
207+
208+
parsedKey.Comment = _comment;
209+
return parsedKey;
210+
}
211+
212+
private static byte[] Argon2(string type, int iterations, int memory, int parallelism, byte[] salt, string passPhrase)
213+
{
214+
int param;
215+
switch (type)
216+
{
217+
case "Argon2i":
218+
param = Argon2Parameters.Argon2i;
219+
break;
220+
case "Argon2d":
221+
param = Argon2Parameters.Argon2d;
222+
break;
223+
case "Argon2id":
224+
param = Argon2Parameters.Argon2id;
225+
break;
226+
default:
227+
throw new SshException("KDF " + type + " is not supported for PuTTY key file");
228+
}
229+
230+
var a2p = new Argon2Parameters.Builder(param)
231+
.WithVersion(Argon2Parameters.Version13)
232+
.WithIterations(iterations)
233+
.WithMemoryAsKB(memory)
234+
.WithParallelism(parallelism)
235+
.WithSalt(salt).Build();
236+
237+
var generator = new Argon2BytesGenerator();
238+
239+
generator.Init(a2p);
240+
241+
var output = new byte[80];
242+
var bytes = generator.GenerateBytes(passPhrase.ToCharArray(), output);
243+
244+
if (bytes != output.Length)
245+
{
246+
throw new SshException("Failed to generate key via Argon2");
247+
}
248+
249+
return output;
250+
}
251+
252+
private static byte[] V2KDF(string passPhrase)
253+
{
254+
var cipherKey = new List<byte>();
255+
256+
var passPhraseBytes = Encoding.UTF8.GetBytes(passPhrase);
257+
for (var sequenceNumber = 0; sequenceNumber < 2; sequenceNumber++)
258+
{
259+
using (var sha1 = SHA1.Create())
260+
{
261+
var sequence = new byte[] { 0, 0, 0, (byte)sequenceNumber };
262+
_ = sha1.TransformBlock(sequence, 0, 4, outputBuffer: null, 0);
263+
_ = sha1.TransformFinalBlock(passPhraseBytes, 0, passPhraseBytes.Length);
264+
Debug.Assert(sha1.Hash != null, "Hash is null");
265+
cipherKey.AddRange(sha1.Hash);
266+
}
267+
}
268+
269+
return cipherKey.ToArray();
270+
}
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)