What if you never knew what is asymmetric encryption?
In short, asymmetric cryptography uses a pair of keys to perform its job. One may think that these two keys are entangled in a sense that while one is used to encrypt the data, to decrypt it back the second key is needed. First encrypts - Second decrypts. You cannot decrypt with the first key! You absolutely need the second key. The keys therefore are generated in pairs. One traditionally is called “private key”, another - “public key”.
Why these names? suppose Alice wants to encrypt some data so only Bob can read it. Bob makes his public key, well, public (say, puts it on the Facebook), but heavily guards the private counterpart. Alice grabs Bob’s public key from the Facebook and encrypts the data with it. Notice where I am going with this? The encrypted data is not readable to anyone, even to Alice. Only Bob’s private key can decrypt it which is in possession only by, well, Bob. One key encrypts, another decrypts, hence it is called “asymmetric”. The other common use of the asymmetric encryption is digital signatures, but in this guide we will concentrate on encryption.
What will you able to do once you complete this guide?
We are going to learn how to use RSA, one of the most widely used asymmetric encryption algorithms in the C# application. We are obviously not going to code the RSA itself, but use its implementation bundled with .Net. This is going to be cross-platform exercise, you can use Mac, Windows or Linux, as long as you have the latest .Net SDK installed. Note that formerly the cross-platform version of .Net was called .Net Core. Now it is simply .Net.
To follow along, you will need a text editor, like Visual studio Code, but your favorite text editor will probably work too. Familiarity with the command line is assumed, I will use bash. On Windows you can use git bash. To generate the RSA keypair, we are going to use openssl
utility. If your system is Mac or Linux, you probably already have it, on Windows it comes with, for example, git bash.
Project setup
create a directory and switch to it. You can call it any way you want, I will call it csharp_rsa
.
mkdir csharp_rsa
cd csharp_rsa
Generate a console C# project
dotnet new console
now, let’s create a key pair
openssl genrsa -out private.pem 2048
This will create a file private.pem
which contains the private key with the key size 2048 bit. The public key is embedded in it also, so let’s extract it:
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
At the moment there should be two files private.pem
and public.pem
with the private and public key, correspondingly.
Let’s rename the Program.cs
file. That is optional, obviously
mv Program.cs CsharpRSA.cs
Do a quick sanity check:
dotnet run
This should print famous “Hello, World!”
Encrypt the data with RSA
We are going to keep all the crypto-related code in the separate file:
touch Cryptor.cs
Put some skeleton code, obviously it won’t do anything at the moment:
namespace csharp_rsa
{
public class Cryptor
{
public byte[] AsymmetricEncrypt(byte[] data)
{
return data;
}
}
}
.Net implements RSA algorithm in the class RSACryptoServiceProvider which lives in the namespace System.Security.Cryptography. Add instance of this class to our Cryptor:
using System.Security.Cryptography; // new
//...
public class Cryptor {
// new
private readonly RSACryptoServiceProvider _rsa = new();
//...
}
this has to be provided with the public key (remember? public key encrypts, private key decrypts). Add the following method to the Cryptor
:
public void SetPublicKey(string publicKeyFile)
{
var publicKey = File.ReadAllText(publicKeyFile);
_rsa.ImportFromPem(publicKey);
}
Naturally, one need to add using System.IO
, so the File.RealAllText
can be used. In case you are wondering, Pem
is a textual format in which RSA keys are often stored.
Now we can ask RSA to actually encrypt the data. Replace the Encrypt
method with the following:
public byte[] AsymmetricEncrypt(byte[] data)
{
return _rsa.Encrypt(data, false);
}
That simple. We set the second parameter to false
to maximize compatibility.
Let’s give it a shot.
our simple command line interface will accept two arguments, the operation encrypt
or decrypt
(to be implemented later) and the corresponding key. The actual data to be encrypted is expected to be passed on the standard input. In the real-world application you will probably read the data from file, from a Gui widget or from some web service.
With that said, replace your Main
function with the following:
static void Main(string[] args)
{
var operation = args[0];
var cryptor = new Cryptor();
if (operation == "encrypt")
{
var publicKeyFile = args[1];
cryptor.SetPublicKey(publicKeyFile);
var data = Encoding.UTF8.GetBytes(Console.In.ReadToEnd());
var encryptedData = cryptor.AsymmetricEncrypt(data);
Console.WriteLine(System.Convert.ToBase64String(encryptedData));
}
}
you will also need to add using System.Text
at the top.
As you see, the code creates our Cryptor
, sets the public key and calls AsymmetricEncrypt
. The result is an array of bytes, so we base64 encode it before printing to the console.
Let’s try it:
echo -n hello | dotnet run encrypt public.pem
Ok, if everything works fine you should see the encrypted data in printed as base64 string. Let’s handle the decryption part.
Decrypt data with RSA
Decrypting the data with RSA is similar to encrypting. The difference is, naturally, that you decrypt with the private key.
add these two functions to the Cryptor
class:
public byte[] AsymmetricDecrypt(byte[] data)
{
return _rsa.Decrypt(data, false);
}
public void SetPrivateKey(string privateKeyFile)
{
var privateKey = File.ReadAllText(privateKeyFile);
_rsa.ImportFromPem(privateKey);
}
The logic should be pretty familiar.
Conversely, let’s add decrypt functionality to the `Main:
static void Main(string[] args)
{
var cryptor = new Cryptor();
if (operation == "encrypt")
{
// ...
}
// new
else if (operation == "decrypt")
{
var privateKeyFile = args[1];
cryptor.SetPrivateKey(privateKeyFile);
var encryptedData = System.Convert.FromBase64String(Console.In.ReadToEnd());
var decryptedData = cryptor.AsymmetricDecrypt(encryptedData);
Console.WriteLine(Encoding.UTF8.GetString(decryptedData));
}
}
We can now decrypt the data back:
echo -n hello | dotnet run encrypt public.pem | dotnet run decrypt private.pem
This encrypt the string “hello” with the public key and decrypts it back with the private key. You should see “hello” printed back.
Are we done then?
If you play a little bit with the program you will notice that it will not work with the large, or even moderate amount of data. The RSA is used to encrypt relatively short data. RSA also slow and compute intensive. Let’s fix that problem.
Hybrid encryption with RSA and AES
Advanced Encryption Standard (AES, for short) is, in essence, a fast and secure encryption algorithm. The fundamental difference with RSA is that it is symmetric, uses the same key for the encryption and the decryption. It is well suitable to encrypt large amounts of data. We are going to compose RSA with AES to achieve our ultimate goal. So here is the plan:
- we generate a reasonably sized random key (the size which RSA can comfortably handle). We’ll call it a “session key”.
- we use AES to encrypt our data using the session key.
- we encrypt the session key with RSA.
- we compose AES-encrypted data and RSA-encrypted session key together.
To decrypt the data, we simply need RSA-decrypt the session key and plug it into AES to decrypt the actual data.
First, we need to be able to generate random data. System.Security.Cryptography
has a class which can help with it.
In the Cryptor
class add the instance of RNGCryptoServiceProvider
and a helper method which generates random data
public class Cryptor
{
private readonly RSACryptoServiceProvider _rsa = new();
// new
private readonly RNGCryptoServiceProvider _rng = new();
// new
private byte[] GenerateRandomData(int size)
{
var data = new byte[size];
_rng.GetBytes(data);
return data;
}
// ...
}
Ok, we can generate random data of any desired size.
For the AES encryption we are going to use the AesGcm
class provided within the System.Security.Cryptography
namespace. In order to encrypt data, in addition to session key it also requires a nonce
, a random value which helps helps defend against certain attacks. In addition to emitting the encrypted data, we’ll also get a tag
from AES, which is used by the implementation to verify integrity of the encrypted message.
Naturally, both nonce and tag must be also passed to the decryption routine.
Let’s add the code for AES encryption and decryption
// ...
public static (byte[], byte[]) SymmetricEncrypt(byte[] data, byte[] sessionKey, byte[] nonce)
{
var gcm = new AesGcm(sessionKey);
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
var encData = new byte[data.Length];
gcm.Encrypt(nonce, data, encData, tag);
return (encData, tag);
}
public static byte[] SymmetricDecrypt(byte[] encryptedData, byte[] sessionKey, byte[] nonce, byte[] tag)
{
var gcm = new AesGcm(sessionKey);
var decData = new byte[encryptedData.Length];
gcm.Decrypt(nonce, encryptedData, tag, decData);
return decData;
}
That handles the AES part.
Let’s put everything together.
As discussed, we generate random session key and random nonce, encrypt the data with the session key, encrypt session key itself with RSA, and concatenate encrypted session key, nonce, tag and encrypted data together.
Upon decryption, we extract the encrypted session key, nonce, tag, use RSA to decrypt the session key, and finally use the session key to decrypt the actual payload
We will use the session key length of 32 bytes, so add this to the Cryptor
class first:
private const int SESSION_KEY_LENGTH = 32;
Now, complete Encrypt implementation:
public byte[] Encrypt(byte[] data)
{
var sessionKey = GenerateRandomData(SESSION_KEY_LENGTH);
var nonce = GenerateRandomData(AesGcm.NonceByteSizes.MaxSize);
var (encryptedData, tag) = SymmetricEncrypt(data, sessionKey, nonce);
var encSessionKey = AsymmetricEncrypt(sessionKey);
var keySizeInBytes = _rsa.KeySize / 8;
var result = new byte[keySizeInBytes + nonce.Length + tag.Length + encryptedData.Length];
Buffer.BlockCopy(encSessionKey, 0, result, 0, encSessionKey.Length);
Buffer.BlockCopy(nonce, 0, result, keySizeInBytes, nonce.Length);
Buffer.BlockCopy(tag, 0, result, keySizeInBytes + nonce.Length, tag.Length);
Buffer.BlockCopy(encryptedData, 0, result, keySizeInBytes + nonce.Length + tag.Length, encryptedData.Length);
return result;
}
The code should be clear now, The minor annoyance is to carefully calculate offsets where all pieces end up in the final byte array.
Finally, complete the Decrypt implementation:
public byte[] Decrypt(byte[] data)
{
var keySizeInBytes = _rsa.KeySize / 8;
var encSessionKey = new byte[keySizeInBytes];
Buffer.BlockCopy(data, 0, encSessionKey, 0, encSessionKey.Length);
var sessionKey = AsymmetricDecrypt(encSessionKey);
var nonce = new byte[AesGcm.NonceByteSizes.MaxSize];
Buffer.BlockCopy(data, keySizeInBytes, nonce, 0, nonce.Length);
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
Buffer.BlockCopy(data, keySizeInBytes + nonce.Length, tag, 0, tag.Length);
var encDataSize = data.Length - (keySizeInBytes + nonce.Length + tag.Length);
var encData = new byte[encDataSize];
Buffer.BlockCopy(data, keySizeInBytes + nonce.Length + tag.Length, encData, 0, encDataSize);
return SymmetricDecrypt(encData, sessionKey, nonce, tag);
}
No surprises here.
Let’s try it.
In fact, the only code change to do in your Main
function is to replace calls to AsymmetricEncrypt
and AsymmetricDecrypt
to Encrypt
and Decrypt
, respectively.
After these trivial renames, run:
echo -n 'hello world' | dotnet run encrypt public.pem | dotnet run decrypt private.pem
This will print the ‘hello world’ back after encrypting and decrypting it. Try larger amount of data, it should also work fine.
What have we learned.
Now we know how to do asymmetric encryption and decryption with RSA in .Net. We also know that it has certain limitations and we have learned how to deal with these limitations by implementing hybrid encryption.