Skip to content

Commit

Permalink
OpenSSH Keyformat v1 Reader with decrypt support
Browse files Browse the repository at this point in the history
Needed to read ED25519 Keys generated by ssh-keygen.
  • Loading branch information
bhalbright authored and darinkes committed Dec 3, 2018
1 parent 00387d4 commit ad7c120
Show file tree
Hide file tree
Showing 8 changed files with 1,157 additions and 4 deletions.
5 changes: 4 additions & 1 deletion src/Renci.SshNet.NET35/Renci.SshNet.NET35.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,9 @@
<Compile Include="..\Renci.SshNet\Security\Cryptography\AsymmetricCipher.cs">
<Link>Security\Cryptography\AsymmetricCipher.cs</Link>
</Compile>
<Compile Include="..\Renci.SshNet\Security\Cryptography\Bcrypt.cs">
<Link>Security\Cryptography\Bcrypt.cs</Link>
</Compile>
<Compile Include="..\Renci.SshNet\Security\Cryptography\BlockCipher.cs">
<Link>Security\Cryptography\BlockCipher.cs</Link>
</Compile>
Expand Down Expand Up @@ -987,4 +990,4 @@
<Target Name="AfterBuild">
</Target>
-->
</Project>
</Project>
30 changes: 29 additions & 1 deletion src/Renci.SshNet.Tests/Classes/PrivateKeyFileTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,34 @@ public void ConstructorWithFileNameAndPassPhraseShouldBeAbleToReadFileThatIsShar
}
}

/// <summary>
/// A test for opening an openssh v1 keyfile where there is no passphrase.
///</summary>
[TestMethod()]
[Owner("bhalbright")]
[TestCategory("PrivateKey")]
public void TestOpenSshV1KeyFileNoPassphrase()
{
using (var stream = GetData("Key.OPENSSH.ED25519.txt"))
{
new PrivateKeyFile(stream);
}
}

/// <summary>
/// A test for opening an openssh v1 keyfile where there is a passphrase.
///</summary>
[TestMethod()]
[Owner("bhalbright")]
[TestCategory("PrivateKey")]
public void TestOpenSshV1KeyFileWithPassphrase()
{
using (var stream = GetData("Key.OPENSSH.ED25519.Encrypted.txt"))
{
new PrivateKeyFile(stream, "password");
}
}

private void SaveStreamToFile(Stream stream, string fileName)
{
var buffer = new byte[4000];
Expand All @@ -567,4 +595,4 @@ private string GetTempFileName()
return tempFile;
}
}
}
}
9 changes: 9 additions & 0 deletions src/Renci.SshNet.Tests/Data/Key.OPENSSH.ED25519.Encrypted.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABBg
HWh+J0IG6OfYxD74SoT9AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIGFd
yflleGqSPOhgSYZf7ZQFlG0zEL9VDGC69UbtaaByAAAAoDLm8u8wFwlqjzZRfVxj
wzGTYFJFtfkHRqfFBE4xKgknHNRbCT1OQb7rgE7nZbUXIlb1NCTZLbXti9AYNZpz
ycvPD4Dc6lB03b8pNHoFVSkrCwxrWB5bKtIM4OZNcDK1lZDBEWE2aZXf9puRHbu3
ccrK/F5GjRi2pUa8qnfqThN1mNPZwFTx4oSKeRaUMdeHBrNwDtaxq32A6Q4KHoYO
KPM=
-----END OPENSSH PRIVATE KEY-----
8 changes: 8 additions & 0 deletions src/Renci.SshNet.Tests/Data/Key.OPENSSH.ED25519.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACANCWZw0K8UGXDQC32WBuyzwFtTGBBr1VuZ43uzpTBjIgAA
AKBATgCiQE4AogAAAAtzc2gtZWQyNTUxOQAAACANCWZw0K8UGXDQC32WBuyzwFtT
GBBr1VuZ43uzpTBjIgAAAEAAzBF1MPUxrs+ycpJh28zzo/F3m6WcKO+orsSbR5Lw
KQ0JZnDQrxQZcNALfZYG7LPAW1MYEGvVW5nje7OlMGMiAAAAFGVkMjU1MTkta2V5
LTIwMTgxMTI3AQIDBAUGBwgJ
-----END OPENSSH PRIVATE KEY-----
6 changes: 5 additions & 1 deletion src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,10 @@
<EmbeddedResource Include="Data\Key.ECDSA384.Encrypted.txt" />
<EmbeddedResource Include="Data\Key.ECDSA521.Encrypted.txt" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Data\Key.OPENSSH.ED25519.Encrypted.txt" />
<EmbeddedResource Include="Data\Key.OPENSSH.ED25519.txt" />
</ItemGroup>
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
Expand All @@ -725,4 +729,4 @@
<Target Name="AfterBuild">
</Target>
-->
</Project>
</Project>
152 changes: 151 additions & 1 deletion src/Renci.SshNet/PrivateKeyFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
using Renci.SshNet.Security.Cryptography.Ciphers.Modes;
using Renci.SshNet.Security.Cryptography.Ciphers.Paddings;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Renci.SshNet.Security.Cryptography;

namespace Renci.SshNet
{
Expand All @@ -23,6 +25,7 @@ namespace Renci.SshNet
/// <remarks>
/// <para>
/// Supports RSA and DSA private key in both <c>OpenSSH</c> and <c>ssh.com</c> format.
/// Also supports ED25519 private key from OpenSSH V1 key file.
/// </para>
/// <para>
/// The following encryption algorithms are supported:
Expand Down Expand Up @@ -203,6 +206,10 @@ private void Open(Stream privateKey, string passPhrase)
HostKey = new KeyHostAlgorithm(_key.ToString(), _key);
break;
#endif
case "OPENSSH":
_key = ParseOpenSshV1Key(decryptedData, passPhrase);
HostKey = new KeyHostAlgorithm(_key.ToString(), _key);
break;
case "SSH2 ENCRYPTED":
var reader = new SshDataReader(decryptedData);
var magicNumber = reader.ReadUInt32();
Expand Down Expand Up @@ -347,7 +354,145 @@ private static byte[] DecryptKey(CipherInfo cipherInfo, byte[] cipherData, strin
return cipher.Decrypt(cipherData);
}

#region IDisposable Members
/// <summary>
/// Parses an OpenSSH V1 key file (i.e. ED25519 key) according to the the key spec:
/// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key.
/// </summary>
/// <param name="keyFileData">the key file data (i.e. base64 encoded data between the header/footer)</param>
/// <param name="passPhrase">passphrase or null if there isn't one</param>
/// <returns></returns>
private ED25519Key ParseOpenSshV1Key(byte [] keyFileData, string passPhrase)
{
var keyReader = new SshDataReader(keyFileData);

//check magic header
var authMagic = Encoding.UTF8.GetBytes("openssh-key-v1\0");
var keyHeaderBytes = keyReader.ReadBytes(authMagic.Length);
if (!authMagic.SequenceEqual(keyHeaderBytes))
{
throw new SshException("This openssh key does not contain the 'openssh-key-v1' format magic header");
}

//cipher will be "aes256-cbc" if using a passphrase, "none" otherwise
var cipherName = keyReader.ReadString(Encoding.UTF8);
//key derivation function (kdf): bcrypt or nothing
var kdfName = keyReader.ReadString(Encoding.UTF8);
//kdf options length: 24 if passphrase, 0 if no passphrase
var kdfOptionsLen = (int)keyReader.ReadUInt32();
byte[] salt = null;
int rounds = 0;
if (kdfOptionsLen > 0)
{
var saltLength = (int)keyReader.ReadUInt32();
salt = keyReader.ReadBytes(saltLength);
rounds = (int)keyReader.ReadUInt32();
}

//number of public keys, only supporting 1 for now
var numberOfPublicKeys = (int)keyReader.ReadUInt32();
if (numberOfPublicKeys != 1)
{
throw new SshException("At this time only one public key in the openssh key is supported.");
}

//length of first public key section
keyReader.ReadUInt32();
var keyType = keyReader.ReadString(Encoding.UTF8);
if(keyType != "ssh-ed25519")
{
throw new SshException("openssh key type: " + keyType + " is not supported");
}

//read public key
var publicKeyLength = (int)keyReader.ReadUInt32(); //32
var publicKey = keyReader.ReadBytes(publicKeyLength);

//possibly encrypted private key
var privateKeyLength = (int)keyReader.ReadUInt32();
var privateKeyBytes = keyReader.ReadBytes(privateKeyLength);

//decrypt private key if necessary
if (cipherName == "aes256-cbc")
{
if (string.IsNullOrEmpty(passPhrase))
{
throw new SshPassPhraseNullOrEmptyException("Private key is encrypted but passphrase is empty.");
}
if (string.IsNullOrEmpty(kdfName) || kdfName != "bcrypt")
{
throw new SshException("kdf " + kdfName + " is not supported for openssh key file");
}

//inspired by the SSHj library (https://github.com/hierynomus/sshj)
//apply the kdf to derive a key and iv from the passphrase
var passPhraseBytes = Encoding.UTF8.GetBytes(passPhrase);
byte[] keyiv = new byte[48];
new BCrypt().Pbkdf(passPhraseBytes, salt, rounds, keyiv);
byte[] key = new byte[32];
Array.Copy(keyiv, 0, key, 0, 32);
byte[] iv = new byte[16];
Array.Copy(keyiv, 32, iv, 0, 16);

//now that we have the key/iv, use a cipher to decrypt the bytes
var cipher = new AesCipher(key, new CbcCipherMode(iv), new PKCS7Padding());
privateKeyBytes = cipher.Decrypt(privateKeyBytes);
}
else if (cipherName != "none")
{
throw new SshException("cipher name " + cipherName + " for openssh key file is not supported");
}

//validate private key length
privateKeyLength = privateKeyBytes.Length;
if (privateKeyLength % 8 != 0)
{
throw new SshException("The private key section must be a multiple of the block size (8)");
}

//now parse the data we called the private key, it actually contains the public key again
//so we need to parse through it to get the private key bytes, plus there's some
//validation we need to do.
var privateKeyReader = new SshDataReader(privateKeyBytes);

//check ints should match, they wouldn't match for example if the wrong passphrase was supplied
int checkInt1 = (int)privateKeyReader.ReadUInt32();
int checkInt2 = (int)privateKeyReader.ReadUInt32();
if (checkInt1 != checkInt2)
{
throw new SshException("The checkints differed, the openssh key was not correctly decoded.");
}

//key type, we already know it is ssh-ed25519
privateKeyReader.ReadString(Encoding.UTF8);

//public key length/bytes (again)
var publicKeyLength2 = (int)privateKeyReader.ReadUInt32();
privateKeyReader.ReadBytes(publicKeyLength2);

//length of private and public key (64)
privateKeyReader.ReadUInt32();
var unencryptedPrivateKey = privateKeyReader.ReadBytes(32);
//public key (again)
privateKeyReader.ReadBytes(32);

//comment, we don't need this but we could log it, not sure if necessary
var comment = privateKeyReader.ReadString(Encoding.UTF8);

//The list of privatekey/comment pairs is padded with the bytes 1, 2, 3, ...
//until the total length is a multiple of the cipher block size.
var padding = privateKeyReader.ReadBytes();
for (int i = 0; i < padding.Length; i++)
{
if ((int)padding[i] != i + 1)
{
throw new SshException("Padding of openssh key format contained wrong byte at position: " + i);
}
}

return new ED25519Key(publicKey.Reverse(), unencryptedPrivateKey);
}

#region IDisposable Members

private bool _isDisposed;

Expand Down Expand Up @@ -415,6 +560,11 @@ public SshDataReader(byte[] data)
return base.ReadBytes(length);
}

public new byte[] ReadBytes()
{
return base.ReadBytes();
}

/// <summary>
/// Reads next mpint data type from internal buffer where length specified in bits.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/Renci.SshNet/Renci.SshNet.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@
<Compile Include="Security\Cryptography\EcdsaDigitalSignature.cs" />
<Compile Include="Security\Cryptography\EcdsaKey.cs" />
<Compile Include="Security\Cryptography\ED25519Key.cs" />
<Compile Include="Security\Cryptography\Bcrypt.cs" />
<Compile Include="Security\Cryptography\HMACMD5.cs" />
<Compile Include="Security\Cryptography\HMACSHA1.cs" />
<Compile Include="Security\Cryptography\HMACSHA256.cs" />
Expand Down
Loading

0 comments on commit ad7c120

Please sign in to comment.