Skip to content

Commit 3ec45e1

Browse files
scott-xuRob-Hague
andauthored
Split PrivateKeyFile into different implementations. (#1542)
* Split PrivateKeyFile into different implementations. * Remove duplicate keyName check. Get cipherName and salt only if the key is PKCS1 format. --------- Co-authored-by: Rob Hague <[email protected]>
1 parent bdaa164 commit 3ec45e1

File tree

5 files changed

+674
-580
lines changed

5 files changed

+674
-580
lines changed
+249
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
#nullable enable
2+
using System;
3+
using System.Globalization;
4+
using System.Linq;
5+
using System.Text;
6+
7+
using Renci.SshNet.Common;
8+
using Renci.SshNet.Security;
9+
using Renci.SshNet.Security.Cryptography;
10+
using Renci.SshNet.Security.Cryptography.Ciphers;
11+
using Renci.SshNet.Security.Cryptography.Ciphers.Modes;
12+
13+
namespace Renci.SshNet
14+
{
15+
public partial class PrivateKeyFile
16+
{
17+
private sealed class OpenSSH : IPrivateKeyParser
18+
{
19+
private readonly byte[] _data;
20+
private readonly string? _passPhrase;
21+
22+
public OpenSSH(byte[] data, string? passPhrase)
23+
{
24+
_data = data;
25+
_passPhrase = passPhrase;
26+
}
27+
28+
/// <summary>
29+
/// Parses an OpenSSH V1 key file according to the key spec:
30+
/// <see href="https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key"/>.
31+
/// </summary>
32+
public Key Parse()
33+
{
34+
var keyReader = new SshDataReader(_data);
35+
36+
// check magic header
37+
var authMagic = "openssh-key-v1\0"u8;
38+
var keyHeaderBytes = keyReader.ReadBytes(authMagic.Length);
39+
if (!authMagic.SequenceEqual(keyHeaderBytes))
40+
{
41+
throw new SshException("This openssh key does not contain the 'openssh-key-v1' format magic header");
42+
}
43+
44+
// cipher will be "aes256-cbc" or other cipher if using a passphrase, "none" otherwise
45+
var cipherName = keyReader.ReadString(Encoding.UTF8);
46+
47+
// key derivation function (kdf): bcrypt or nothing
48+
var kdfName = keyReader.ReadString(Encoding.UTF8);
49+
50+
// kdf options length: 24 if passphrase, 0 if no passphrase
51+
var kdfOptionsLen = (int)keyReader.ReadUInt32();
52+
byte[]? salt = null;
53+
var rounds = 0;
54+
if (kdfOptionsLen > 0)
55+
{
56+
var saltLength = (int)keyReader.ReadUInt32();
57+
salt = keyReader.ReadBytes(saltLength);
58+
rounds = (int)keyReader.ReadUInt32();
59+
}
60+
61+
// number of public keys, only supporting 1 for now
62+
var numberOfPublicKeys = (int)keyReader.ReadUInt32();
63+
if (numberOfPublicKeys != 1)
64+
{
65+
throw new SshException("At this time only one public key in the openssh key is supported.");
66+
}
67+
68+
// read public key in ssh-format, but we dont need it
69+
_ = keyReader.ReadString(Encoding.UTF8);
70+
71+
// possibly encrypted private key
72+
var privateKeyLength = (int)keyReader.ReadUInt32();
73+
byte[] privateKeyBytes;
74+
75+
// decrypt private key if necessary
76+
if (cipherName != "none")
77+
{
78+
if (string.IsNullOrEmpty(_passPhrase))
79+
{
80+
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
81+
}
82+
83+
if (string.IsNullOrEmpty(kdfName) || kdfName != "bcrypt")
84+
{
85+
throw new SshException("kdf " + kdfName + " is not supported for openssh key file");
86+
}
87+
88+
var ivLength = 16;
89+
CipherInfo cipherInfo;
90+
switch (cipherName)
91+
{
92+
case "3des-cbc":
93+
ivLength = 8;
94+
cipherInfo = new CipherInfo(192, (key, iv) => new TripleDesCipher(key, new CbcCipherMode(iv), padding: null));
95+
break;
96+
case "aes128-cbc":
97+
cipherInfo = new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false));
98+
break;
99+
case "aes192-cbc":
100+
cipherInfo = new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false));
101+
break;
102+
case "aes256-cbc":
103+
cipherInfo = new CipherInfo(256, (key, iv) => new AesCipher(key, iv, AesCipherMode.CBC, pkcs7Padding: false));
104+
break;
105+
case "aes128-ctr":
106+
cipherInfo = new CipherInfo(128, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false));
107+
break;
108+
case "aes192-ctr":
109+
cipherInfo = new CipherInfo(192, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false));
110+
break;
111+
case "aes256-ctr":
112+
cipherInfo = new CipherInfo(256, (key, iv) => new AesCipher(key, iv, AesCipherMode.CTR, pkcs7Padding: false));
113+
break;
114+
115+
cipherInfo = new CipherInfo(128, (key, iv) => new AesGcmCipher(key, iv, aadLength: 0), isAead: true);
116+
break;
117+
118+
cipherInfo = new CipherInfo(256, (key, iv) => new AesGcmCipher(key, iv, aadLength: 0), isAead: true);
119+
break;
120+
121+
ivLength = 12;
122+
cipherInfo = new CipherInfo(256, (key, iv) => new ChaCha20Poly1305Cipher(key, aadLength: 0), isAead: true);
123+
break;
124+
default:
125+
throw new SshException("Cipher '" + cipherName + "' is not supported for an OpenSSH key.");
126+
}
127+
128+
var keyLength = cipherInfo.KeySize / 8;
129+
130+
// inspired by the SSHj library (https://github.com/hierynomus/sshj)
131+
// apply the kdf to derive a key and iv from the passphrase
132+
var passPhraseBytes = Encoding.UTF8.GetBytes(_passPhrase);
133+
var keyiv = new byte[keyLength + ivLength];
134+
new BCrypt().Pbkdf(passPhraseBytes, salt, rounds, keyiv);
135+
136+
var key = keyiv.Take(keyLength);
137+
var iv = keyiv.Take(keyLength, ivLength);
138+
139+
var cipher = cipherInfo.Cipher(key, iv);
140+
141+
// The authentication tag data (if any) is concatenated to the end of the encrypted private key string.
142+
// See https://github.com/openssh/openssh-portable/blob/509b757c052ea969b3a41fc36818b44801caf1cf/sshkey.c#L2951
143+
// and https://github.com/openssh/openssh-portable/blob/509b757c052ea969b3a41fc36818b44801caf1cf/cipher.c#L340
144+
var cipherData = keyReader.ReadBytes(privateKeyLength + cipher.TagSize);
145+
146+
try
147+
{
148+
privateKeyBytes = cipher.Decrypt(cipherData, 0, privateKeyLength);
149+
}
150+
finally
151+
{
152+
if (cipher is IDisposable disposable)
153+
{
154+
disposable.Dispose();
155+
}
156+
}
157+
}
158+
else
159+
{
160+
privateKeyBytes = keyReader.ReadBytes(privateKeyLength);
161+
}
162+
163+
// validate private key length
164+
privateKeyLength = privateKeyBytes.Length;
165+
if (privateKeyLength % 8 != 0)
166+
{
167+
throw new SshException("The private key section must be a multiple of the block size (8)");
168+
}
169+
170+
// now parse the data we called the private key, it actually contains the public key again
171+
// so we need to parse through it to get the private key bytes, plus there's some
172+
// validation we need to do.
173+
var privateKeyReader = new SshDataReader(privateKeyBytes);
174+
175+
// check ints should match, they wouldn't match for example if the wrong passphrase was supplied
176+
var checkInt1 = (int)privateKeyReader.ReadUInt32();
177+
var checkInt2 = (int)privateKeyReader.ReadUInt32();
178+
if (checkInt1 != checkInt2)
179+
{
180+
throw new SshException(string.Format(CultureInfo.InvariantCulture,
181+
"The random check bytes of the OpenSSH key do not match ({0} <-> {1}).",
182+
checkInt1.ToString(CultureInfo.InvariantCulture),
183+
checkInt2.ToString(CultureInfo.InvariantCulture)));
184+
}
185+
186+
// key type
187+
var keyType = privateKeyReader.ReadString(Encoding.UTF8);
188+
189+
Key parsedKey;
190+
byte[] publicKey;
191+
byte[] unencryptedPrivateKey;
192+
switch (keyType)
193+
{
194+
case "ssh-ed25519":
195+
// https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent-11#section-3.2.3
196+
197+
// ENC(A)
198+
_ = privateKeyReader.ReadBignum2();
199+
200+
// k || ENC(A)
201+
unencryptedPrivateKey = privateKeyReader.ReadBignum2();
202+
parsedKey = new ED25519Key(unencryptedPrivateKey);
203+
break;
204+
case "ecdsa-sha2-nistp256":
205+
case "ecdsa-sha2-nistp384":
206+
case "ecdsa-sha2-nistp521":
207+
// curve
208+
var len = (int)privateKeyReader.ReadUInt32();
209+
var curve = Encoding.ASCII.GetString(privateKeyReader.ReadBytes(len));
210+
211+
// public key
212+
publicKey = privateKeyReader.ReadBignum2();
213+
214+
// private key
215+
unencryptedPrivateKey = privateKeyReader.ReadBignum2();
216+
parsedKey = new EcdsaKey(curve, publicKey, unencryptedPrivateKey.TrimLeadingZeros());
217+
break;
218+
case "ssh-rsa":
219+
var modulus = privateKeyReader.ReadBignum(); // n
220+
var exponent = privateKeyReader.ReadBignum(); // e
221+
var d = privateKeyReader.ReadBignum(); // d
222+
var inverseQ = privateKeyReader.ReadBignum(); // iqmp
223+
var p = privateKeyReader.ReadBignum(); // p
224+
var q = privateKeyReader.ReadBignum(); // q
225+
parsedKey = new RsaKey(modulus, exponent, d, p, q, inverseQ);
226+
break;
227+
default:
228+
throw new SshException("OpenSSH key type '" + keyType + "' is not supported.");
229+
}
230+
231+
parsedKey.Comment = privateKeyReader.ReadString(Encoding.UTF8);
232+
233+
// The list of privatekey/comment pairs is padded with the bytes 1, 2, 3, ...
234+
// until the total length is a multiple of the cipher block size.
235+
var padding = privateKeyReader.ReadBytes();
236+
for (var i = 0; i < padding.Length; i++)
237+
{
238+
if ((int)padding[i] != i + 1)
239+
{
240+
throw new SshException("Padding of openssh key format contained wrong byte at position: " +
241+
i.ToString(CultureInfo.InvariantCulture));
242+
}
243+
}
244+
245+
return parsedKey;
246+
}
247+
}
248+
}
249+
}

0 commit comments

Comments
 (0)