Skip to content

Commit 2a0c70b

Browse files
committed
[Private Key] Add support for PuTTY private key
1 parent 3ec45e1 commit 2a0c70b

18 files changed

+575
-2
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<ItemGroup>
77
<PackageVersion Include="Appveyor.TestLogger" Version="2.0.0" />
88
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
9-
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.4.0" />
9+
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.5.0-beta.105" />
1010
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
1111
<PackageVersion Include="coverlet.msbuild" Version="6.0.2" />
1212
<PackageVersion Include="LiquidTestReports.Markdown" Version="1.0.9" />
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
hmac = new HMACSHA256(keyData.Take(48, 32));
97+
98+
break;
99+
case "2":
100+
keyData = V2KDF(_passPhrase);
101+
102+
cipherKey = keyData.Take(32);
103+
cipherIV = new byte[16];
104+
105+
using (var sha1 = SHA1.Create())
106+
{
107+
var part1 = Encoding.UTF8.GetBytes("putty-private-key-file-mac-key");
108+
var part2 = Encoding.UTF8.GetBytes(_passPhrase);
109+
_ = sha1.TransformBlock(part1, 0, part1.Length, outputBuffer: null, 0);
110+
_ = sha1.TransformFinalBlock(part2, 0, part2.Length);
111+
hmac = new HMACSHA1(sha1.Hash.Take(20));
112+
}
113+
114+
break;
115+
default:
116+
throw new SshException("PuTTY key file version " + _version + " is not supported");
117+
}
118+
119+
var cipher = new AesCipher(cipherKey, cipherIV, AesCipherMode.CBC, pkcs7Padding: false);
120+
privateKey = cipher.Decrypt(_data);
121+
122+
break;
123+
case "none":
124+
switch (_version)
125+
{
126+
case "3":
127+
hmac = new HMACSHA256(Array.Empty<byte>());
128+
break;
129+
case "2":
130+
var hash = CryptoAbstraction.HashSHA1(Encoding.UTF8.GetBytes("putty-private-key-file-mac-key"));
131+
hmac = new HMACSHA1(hash);
132+
break;
133+
default:
134+
throw new SshException("PuTTY key file version " + _version + " is not supported");
135+
}
136+
137+
privateKey = _data;
138+
break;
139+
default:
140+
throw new SshException("encryption " + _encryptionType + " is not supported for PuTTY key file");
141+
}
142+
143+
using (var macData = new SshDataStream(256))
144+
{
145+
macData.Write(_algorithmName, Encoding.UTF8);
146+
macData.Write(_encryptionType, Encoding.UTF8);
147+
macData.Write(_comment, Encoding.UTF8);
148+
macData.WriteBinary(_publicKey);
149+
macData.WriteBinary(privateKey);
150+
try
151+
{
152+
var encoded = hmac.ComputeHash(macData.ToArray());
153+
#if NET
154+
var reference = Convert.FromHexString(_mac);
155+
#else
156+
var reference = Org.BouncyCastle.Utilities.Encoders.Hex.Decode(_mac);
157+
#endif
158+
if (!encoded.SequenceEqual(reference))
159+
{
160+
throw new SshException("MAC verification failed for PuTTY key file");
161+
}
162+
}
163+
finally
164+
{
165+
hmac.Dispose();
166+
}
167+
}
168+
169+
var publicKeyReader = new SshDataReader(_publicKey);
170+
var keyType = publicKeyReader.ReadString(Encoding.UTF8);
171+
Debug.Assert(keyType == _algorithmName, $"{nameof(keyType)} is not the same with {nameof(_algorithmName)}");
172+
173+
var privateKeyReader = new SshDataReader(privateKey);
174+
175+
Key parsedKey;
176+
177+
switch (keyType)
178+
{
179+
case "ssh-ed25519":
180+
parsedKey = new ED25519Key(privateKeyReader.ReadBignum2());
181+
break;
182+
case "ecdsa-sha2-nistp256":
183+
case "ecdsa-sha2-nistp384":
184+
case "ecdsa-sha2-nistp512":
185+
var curve = publicKeyReader.ReadString(Encoding.ASCII);
186+
var pub = publicKeyReader.ReadBignum2();
187+
var prv = privateKeyReader.ReadBignum2();
188+
parsedKey = new EcdsaKey(curve, pub, prv);
189+
break;
190+
case "ssh-dss":
191+
var p = publicKeyReader.ReadBignum();
192+
var q = publicKeyReader.ReadBignum();
193+
var g = publicKeyReader.ReadBignum();
194+
var y = publicKeyReader.ReadBignum();
195+
var x = privateKeyReader.ReadBignum();
196+
parsedKey = new DsaKey(p, q, g, y, x);
197+
break;
198+
case "ssh-rsa":
199+
var exponent = publicKeyReader.ReadBignum(); // e
200+
var modulus = publicKeyReader.ReadBignum(); // n
201+
var d = privateKeyReader.ReadBignum(); // d
202+
p = privateKeyReader.ReadBignum(); // p
203+
q = privateKeyReader.ReadBignum(); // q
204+
var inverseQ = privateKeyReader.ReadBignum(); // iqmp
205+
parsedKey = new RsaKey(modulus, exponent, d, p, q, inverseQ);
206+
break;
207+
default:
208+
throw new SshException("key type " + keyType + " is not supported for PuTTY key file");
209+
}
210+
211+
parsedKey.Comment = _comment;
212+
return parsedKey;
213+
}
214+
215+
private static byte[] Argon2(string type, int iterations, int memory, int parallelism, byte[] salt, string passPhrase)
216+
{
217+
int param;
218+
switch (type)
219+
{
220+
case "Argon2i":
221+
param = Argon2Parameters.Argon2i;
222+
break;
223+
case "Argon2d":
224+
param = Argon2Parameters.Argon2d;
225+
break;
226+
case "Argon2id":
227+
param = Argon2Parameters.Argon2id;
228+
break;
229+
default:
230+
throw new SshException("kdf " + type + " is not supported for PuTTY key file");
231+
}
232+
233+
var a2p = new Argon2Parameters.Builder(param)
234+
.WithVersion(Argon2Parameters.Version13)
235+
.WithIterations(iterations)
236+
.WithMemoryAsKB(memory)
237+
.WithParallelism(parallelism)
238+
.WithSalt(salt).Build();
239+
240+
var generator = new Argon2BytesGenerator();
241+
242+
generator.Init(a2p);
243+
244+
var output = new byte[80];
245+
var bytes = generator.GenerateBytes(passPhrase.ToCharArray(), output);
246+
247+
if (bytes != output.Length)
248+
{
249+
throw new SshException("Failed to generate key via Argon2");
250+
}
251+
252+
return output;
253+
}
254+
255+
private static byte[] V2KDF(string passphrase)
256+
{
257+
var cipherKey = new List<byte>();
258+
259+
var passwordBytes = Encoding.UTF8.GetBytes(passphrase);
260+
for (var sequenceNumber = 0; sequenceNumber < 2; sequenceNumber++)
261+
{
262+
using (var sha1 = SHA1.Create())
263+
{
264+
var sequence = new byte[] { 0, 0, 0, (byte)sequenceNumber };
265+
_ = sha1.TransformBlock(sequence, 0, 4, outputBuffer: null, 0);
266+
_ = sha1.TransformFinalBlock(passwordBytes, 0, passwordBytes.Length);
267+
Debug.Assert(sha1.Hash != null, "Hash is null");
268+
cipherKey.AddRange(sha1.Hash);
269+
}
270+
}
271+
272+
return cipherKey.ToArray();
273+
}
274+
}
275+
}
276+
}

src/Renci.SshNet/PrivateKeyFile.cs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,25 @@ namespace Renci.SshNet
111111
public partial class PrivateKeyFile : IPrivateKeySource, IDisposable
112112
{
113113
private const string PrivateKeyPattern = @"^-+ *BEGIN (?<keyName>\w+( \w+)*) *-+\r?\n((Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: (?<cipherName>[A-Z0-9-]+),(?<salt>[a-fA-F0-9]+)\r?\n\r?\n)|(Comment: ""?[^\r\n]*""?\r?\n))?(?<data>([a-zA-Z0-9/+=]{1,80}\r?\n)+)(\r?\n)?-+ *END \k<keyName> *-+";
114+
private const string PrivateKeyPuTTYPattern = @"^(?<keyName>PuTTY-User-Key-File)-(?<version>\d+): (?<algorithmName>[\w-]+)\r?\nEncryption: (?<encryptionType>[\w-]+)\nComment: (?<comment>.*)\r?\nPublic-Lines: \d+\r?\n(?<publicKey>(([a-zA-Z0-9/+=]{1,64})\r?\n)+)(Key-Derivation: (?<argon2Type>\w+)\r?\nArgon2-Memory: (?<argon2Memory>\d+)\r?\nArgon2-Passes: (?<argon2Passes>\d+)\r?\nArgon2-Parallelism: (?<argon2Parallelism>\d+)\r?\nArgon2-Salt: (?<argon2Salt>[\w\d]+)\r?\n)?Private-Lines: \d+\r?\n(?<data>(([a-zA-Z0-9/+=]{1,64})\r?\n)+)+Private-MAC: (?<mac>[\w\d]+)";
114115
private const string CertificatePattern = @"(?<type>[-\w]+@openssh\.com)\s(?<data>[a-zA-Z0-9\/+=]*)(\s+(?<comment>.*))?";
115116

116117
#if NET7_0_OR_GREATER
117118
private static readonly Regex PrivateKeyRegex = GetPrivateKeyRegex();
119+
private static readonly Regex PrivateKeyPuTTYRegex = GetPrivateKeyPuTTYRegex();
118120
private static readonly Regex CertificateRegex = GetCertificateRegex();
119121

120122
[GeneratedRegex(PrivateKeyPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)]
121123
private static partial Regex GetPrivateKeyRegex();
122124

125+
[GeneratedRegex(PrivateKeyPuTTYPattern, RegexOptions.Multiline | RegexOptions.ExplicitCapture)]
126+
private static partial Regex GetPrivateKeyPuTTYRegex();
127+
123128
[GeneratedRegex(CertificatePattern, RegexOptions.ExplicitCapture)]
124129
private static partial Regex GetCertificateRegex();
125130
#else
126131
private static readonly Regex PrivateKeyRegex = new Regex(PrivateKeyPattern, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
132+
private static readonly Regex PrivateKeyPuTTYRegex = new Regex(PrivateKeyPuTTYPattern, RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
127133
private static readonly Regex CertificateRegex = new Regex(CertificatePattern, RegexOptions.Compiled | RegexOptions.ExplicitCapture);
128134
#endif
129135

@@ -287,7 +293,14 @@ private void Open(Stream privateKey, string? passPhrase)
287293
using (var sr = new StreamReader(privateKey))
288294
{
289295
var text = sr.ReadToEnd();
290-
privateKeyMatch = PrivateKeyRegex.Match(text);
296+
if (text.StartsWith("PuTTY-User-Key-File", StringComparison.Ordinal))
297+
{
298+
privateKeyMatch = PrivateKeyPuTTYRegex.Match(text);
299+
}
300+
else
301+
{
302+
privateKeyMatch = PrivateKeyRegex.Match(text);
303+
}
291304
}
292305

293306
if (!privateKeyMatch.Success)
@@ -321,6 +334,34 @@ private void Open(Stream privateKey, string? passPhrase)
321334
case "SSH2 ENCRYPTED PRIVATE KEY":
322335
parser = new SSHCOM(binaryData, passPhrase);
323336
break;
337+
case "PuTTY-User-Key-File":
338+
var version = privateKeyMatch.Result("${version}");
339+
var algorithmName = privateKeyMatch.Result("${algorithmName}");
340+
var encryptionType = privateKeyMatch.Result("${encryptionType}");
341+
var comment = privateKeyMatch.Result("${comment}");
342+
var publicKey = privateKeyMatch.Result("${publicKey}");
343+
var argon2Type = privateKeyMatch.Result("${argon2Type}");
344+
var argon2Memory = privateKeyMatch.Result("${argon2Memory}");
345+
var argon2Passes = privateKeyMatch.Result("${argon2Passes}");
346+
var argon2Parallelism = privateKeyMatch.Result("${argon2Parallelism}");
347+
var argon2Salt = privateKeyMatch.Result("${argon2Salt}");
348+
var mac = privateKeyMatch.Result("${mac}");
349+
350+
parser = new PuTTY(
351+
version,
352+
algorithmName,
353+
encryptionType,
354+
comment,
355+
Convert.FromBase64String(publicKey),
356+
argon2Type,
357+
argon2Salt,
358+
argon2Passes,
359+
argon2Memory,
360+
argon2Parallelism,
361+
binaryData,
362+
mac,
363+
passPhrase);
364+
break;
324365
default:
325366
throw new NotSupportedException(string.Format(CultureInfo.CurrentCulture, "Key '{0}' is not supported.", keyName));
326367
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
PuTTY-User-Key-File-2: ssh-ed25519
2+
Encryption: aes256-cbc
3+
Comment: Key.OPENSSH.ED25519
4+
Public-Lines: 2
5+
AAAAC3NzaC1lZDI1NTE5AAAAIA0JZnDQrxQZcNALfZYG7LPAW1MYEGvVW5nje7Ol
6+
MGMi
7+
Private-Lines: 1
8+
f3N2/AkwbgVeXdK155h+JCbWgBXyEk3qEyx+ChUqm4tOUQGiJ95/mTo4RbIjWn+2
9+
Private-MAC: 823817b8364ce7f52e278e252fd10e2f51ac8554

test/Data/Key.PuTTY2.Ed25519.ppk

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
PuTTY-User-Key-File-2: ssh-ed25519
2+
Encryption: none
3+
Comment: Key.OPENSSH.ED25519
4+
Public-Lines: 2
5+
AAAAC3NzaC1lZDI1NTE5AAAAIA0JZnDQrxQZcNALfZYG7LPAW1MYEGvVW5nje7Ol
6+
MGMi
7+
Private-Lines: 1
8+
AAAAIADMEXUw9TGuz7JykmHbzPOj8XebpZwo76iuxJtHkvAp
9+
Private-MAC: 6a1329739932b5caaaf48d7fcd61392d498b8f5f

0 commit comments

Comments
 (0)