Building a chat app with Blazor WASM, SignalR and post-quantum end-to-end encryption

Β· 2341 words Β· 11 minutes to read

I previously blogged about post-quantum cryptography on this blog a few times. Among other things, I released a set of helper libraries for working with Dilithium in .NET and Duende Identity Server, as well as shared some general samples on post-quantum cryptography in .NET.

Earlier this month, in a big milestone, NIST released the first 3 finalized Post-Quantum encryption standards. I thought it might be nice to celebrate this by building a simple chat application with Blazor WASM and SignalR, that uses post-quantum cryptography for end-to-end encryption.

Background πŸ”—

This official standardization is a big deal for the industry, as it provides a solid foundation for building secure systems that are resistant to quantum attacks. NIST is now actively encouraging developers and system administrators to transition to those standards. Federal and government agencies are expected to start mandating migration to those post-quantum cryptography very soon as well.

The standards are:

  • FIPS-203, intended as the primary standard for general encryption, based on the CRYSTALS-Kyber algorithm, which has been renamed ML-KEM
  • FIPS-204, intended as the primary standard for protecting digital signatures. Based on CRYSTALS-Dilithium algorithm, which has been renamed ML-DSA
  • FIPS 205, also designed for digital signatures. The standard is based on the Sphincs+ algorithm, which has been renamed SLH-DSA

Disclaimer πŸ”—

We are going to put together a simple demo chat application in this post, however it is important to note that this is only a basic example and should not be used as an exhaustive reference for building secure applications. The purpose of this post is to be educational, help you get started and demonstrate the general direction you can take with regards to employing the new post-quantum cryptography standards in web applications.

For a more exhaustive reference on end to end encryption, please refer to established protocols such as Post-Quantum Extended Diffie-Hellman implemented by Signal.

Building the chat app - server πŸ”—

If you want to see the end result already, or you do follow along, but some piece of code does not make sense at any point, you can find the full source code for this demo in the GitHub repository.

To get going, we need a .NET solution, with a Blazor WebAssembly frontend project and a SignalR (ASP.NET Core) backend project. The ASP.NET Core application will be responsible for handling the SignalR connections and broadcasting messages between clients. For our purposes, a single hub BlazorChatSampleHub is enough.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddCors(options =>
{
    options.AddPolicy("default",
        builder =>
        {
            builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()
                .SetIsOriginAllowedToAllowWildcardSubdomains();
        });
});
var app = builder.Build();
app.UseCors("default");

app.MapHub<BlazorChatSampleHub>("/chat");
app.Run();

We also enable CORS for all origins, as we will be running the Blazor WASM app on a different port.

The hub itself is pretty much a standard one for this type of a sample app. It keeps track of connected users in a local dictionary, and exposes an explicit method of joining the chat, where the user joins not only with a username, but also with a public key.

In response to the join request, the hub broadcasts the new user to all other clients, and sends the list of all users to the newly joined client. There are two additional methods on the hub, Negotiate and DirectMessage, which will act as relays for the handshake and the encrypted message exchange between users - we will implement them later though.

public class BlazorChatSampleHub : Hub
{
    private static readonly ConcurrentDictionary<string, User> Users = new ConcurrentDictionary<string, User>();

    public async Task GetAllUsers()
    {
        await Clients.Caller.SendAsync("AllUsers", Users.Select(u => u.Value).ToArray());
    }

    public async Task Join(string username, byte[] publicKey)
    {
        var user = new User
        {
            Username = username,
            ConnectionId = Context.ConnectionId,
            PublicKey = publicKey
        };
        
        if (Users.ContainsKey(username))
        {
            throw new Exception("User already joined!");
        }

        if (Users.TryAdd(username, user))
        {
            await Clients.Others.SendAsync("UserJoined", username, publicKey);
            await Clients.Caller.SendAsync("UserList", Users.Select(u => u.Value).ToArray());
        }
    }

    public override async Task OnDisconnectedAsync(Exception e)
    {
        var user = Users.FirstOrDefault(u => u.Value.ConnectionId == Context.ConnectionId);

        if (user.Value != null)
        {
            if (Users.TryRemove(user))
            {
                Console.WriteLine($"Removed user {user.Value.Username}");
            }
        }
        
        await base.OnDisconnectedAsync(e);
    }

    public async Task Negotiate(string username, byte[] encapsulatedSecret)
    {
        // TODO
    }
    
    public async Task DirectMessage(string username, byte[] encryptedDirectMessage)
    {
        // TODO
    }
}

internal class User
{
    public string ConnectionId { get; set; }
    public string Username { get; set; }
    public byte[] PublicKey { get; set; }
}

Building the chat app - client πŸ”—

With the server side almost ready, we can shift our attention to the client side, which is going to be a lot more interesting, as this is where the key exchange and the subsequent encryption/decryption will be implemented. The client project will be Blazor WASM, with the latest (at the time of writing) version of .NET, 9.0.0-preview.7.24406.2. We also need to reference the SignalR client and the BouncyCastle library, which provides the implementation for post-quantum cryptography algorithms.

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0-preview.7.24406.2" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.0-preview.7.24406.2" PrivateAssets="all" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0-preview.7.24406.2" />
  </ItemGroup>

</Project>

We will only need a single page to handle everything, so let’s add it, called Chat.razor. The simple UI will present the user with a textbox to type in the username, and allow to connect to the chat. Once connected, there is also an option to disconnect.

A connected user should also see a user list, though we will implement this part later. The user list will be populated with the list of all users currently in the chat.

@page "/"
@inject NavigationManager navigationManager
@using Microsoft.AspNetCore.SignalR.Client;
@using Org.BouncyCastle.Crypto
@using Org.BouncyCastle.Pqc.Crypto.Crystals.Kyber
@using Org.BouncyCastle.Security
@using System.Text

<h1>Blazor SignalR Post-Quantum Encryption Sample</h1>
<hr />

@if (!_isChatting)
{
    <p>
        Enter username:
    </p>

    <input type="text" maxlength="32" @bind="@_username" />
    <button type="button" class="btn btn-sm btn-primary ml-md-auto" @onclick="@ChatConnect"><span class="oi oi-chat" aria-hidden="true"></span> Chat!</button>
}
else
{
    <div class="alert alert-secondary mt-4" role="alert">
        <span class="oi oi-person mr-2" aria-hidden="true"></span>
        <span>Joined as <b>@_username</b></span>
        <button type="button" class="btn btn-sm btn-warning ml-md-auto" @onclick="@DisconnectAsync">Disconnect</button>
    </div>

    @if (string.IsNullOrEmpty(_error) == false)
    {
        <div class="alert alert-danger mt-4" role="alert">
            <span class="oi oi-warning mr-2" aria-hidden="true"></span>
            <span><b>@_error</b></span>
        </div>
    }

    <div>
        @foreach (var user in _users)
        {
          <!-- TODO -->
        }
    </div>
}

Upon arriving on the page, we will generate a private-public pair of Kyber (FIPS-203) keys. The private key will be stored locally, while the public key will be sent to the server upon joining the chat. Note that our client app is Blazor WebAssembly, so the private key will never leave the client and remains unknown to the server.

We also provide the implementation of the handlers for the Join and UserJoined SignalR events. As already mentioned, the Join event is triggered by the hub when a new user joins the chat, and the UserJoined event is triggered when the server broadcasts a new user to all clients, and we want the list of all users to be always up-to-date.

@code {
    private bool _isChatting = false;
    private string _username;
    private string _directMessage;
    private string _error;

    private List<User> _users = new List<User>();
    private HubConnection _hubConnection;

    private SecureRandom _random = new SecureRandom();
    private KyberPublicKeyParameters _kyberPublicKey;
    private KyberPrivateKeyParameters _kyberPrivateKey;

    protected override void OnInitialized()
    {
        var kyberPair = GenerateKyberPair();
        _kyberPublicKey = (KyberPublicKeyParameters)kyberPair.Public;
        _kyberPrivateKey = (KyberPrivateKeyParameters)kyberPair.Private;
    }

    public async Task ChatConnect()
    {
        try
        {
            _error = null;
            _isChatting = true;
            await Task.Delay(1);

            _hubConnection = new HubConnectionBuilder()
                .WithUrl("https://localhost:7015/chat")
                .Build();

            _hubConnection.On<string, byte[]>("UserJoined", UserJoined);
            _hubConnection.On<ICollection<User>>("UserList", UserList);

            await _hubConnection.StartAsync();
            await _hubConnection.SendAsync("Join", _username, _kyberPublicKey.GetEncoded());
        }
        catch (Exception e)
        {
            _error = $"ERROR: {e.Message}";
            _isChatting = false;
        }
    }

    private AsymmetricCipherKeyPair GenerateKyberPair()
    {
        var keyGenParameters = new KyberKeyGenerationParameters(_random, KyberParameters.kyber768);

        var kyberKeyPairGenerator = new KyberKeyPairGenerator();
        kyberKeyPairGenerator.Init(keyGenParameters);

        var keyPair = kyberKeyPairGenerator.GenerateKeyPair();
        return keyPair;
    }

    private void UserJoined(string username, byte[] publicKey)
    {
        _users.Add(new User { Username = username, PublicKey = publicKey });
        InvokeAsync(StateHasChanged);
    }

    private void UserList(ICollection<User> users)
    {
        _users = users.Where(u => u.Username != _username).ToList();
        InvokeAsync(StateHasChanged);
    }

    private async Task DisconnectAsync()
    {
        if (_isChatting)
        {
            await _hubConnection.StopAsync();
            await _hubConnection.DisposeAsync();

            _hubConnection = null;
            _users.Clear();
            _isChatting = false;
        }
    }
}

The DTOs involved are:

public class User
{
    public string Username { get; set; }
    public byte[] PublicKey { get; set; }
    public byte[] NegotiatedSharedSecret { get; set; }
    public List<Message> DirectMessages { get; set; } = new List<Message>();
}

public class Message
{
    public Message(string body, bool own)
    {
        Body = body;
        Own = own;
    }
    public string Body { get; set; }
    public bool Own { get; set; }
}

So far so good, but we have not discussed the most important part - the encryption. We will start by adding an extra method to the Blazor UI, which will be responsible for initiating the key exchange with another user. The method will be called NegotiateSecret and will be triggered by a button click.

private async Task NegotiateSecret(User user)
{
    var kyberKemGenerator = new KyberKemGenerator(_random);
    var kyberParameters = new KyberPublicKeyParameters(KyberParameters.kyber768, user.PublicKey);
    var encapsulatedSecret = kyberKemGenerator.GenerateEncapsulated(kyberParameters);
    var sharedSecret = encapsulatedSecret.GetSecret();
    user.NegotiatedSharedSecret = sharedSecret;

    var cipherText = encapsulatedSecret.GetEncapsulation();
    await _hubConnection.SendAsync("Negotiate", user.Username, cipherText);
    await InvokeAsync(StateHasChanged);
}

The method makes use of Kyber’s KEM (Key Encapsulation Mechanism) to generate an encapsulated secret using the other user’s public key, which is then sent to the server. The raw secret is 256 bits long and suitable for symmetric encryption with AES-256. The server will relay this secret to the target user, who will then be able to decrypt it using their own private key and obtain the shared secret.

We then have to implement the Negotiate method on the hub, which will be responsible for relaying the key exchange between the two users willing to engage in a direct chat.

public async Task Negotiate(string username, byte[] encapsulatedSecret)
{
    if (!Users.TryGetValue(username, out var targetUser))
    {
        throw new Exception("Target user not found!");
    }

    var sourceUser = Users.FirstOrDefault(u => u.Value.ConnectionId == Context.ConnectionId);
    if (sourceUser.Value == null)
    {
        throw new Exception("Source user not found!");
    }

    await Clients.Client(targetUser.ConnectionId).SendAsync("IncomingNegotiation", sourceUser.Value.Username, encapsulatedSecret);
}

Finally, the other side (also in Blazor client), has to implement the IncomingNegotiation method, which will be triggered by the server when the target user receives the key exchange request. There, the client will extract the shared secret from the encapsulated secret, and store it in the user object.

private void IncomingNegotation(string from, byte[] encapsulatedSecret)
{
    var user = _users.FirstOrDefault(u => u.Username == from);
    if (user == null)
    {
        Console.WriteLine($"ERROR: Received negotiation from unknown user '{from}'.");
        return;
    }

    var kemExtractor = new KyberKemExtractor(_kyberPrivateKey);
    var sharedSecret = kemExtractor.ExtractSecret(encapsulatedSecret);
    user.NegotiatedSharedSecret = sharedSecret;
    InvokeAsync(StateHasChanged);
}

We still need to ensure the event handler is wired up, so that the client knows how to handle the incoming negotiation request.

_hubConnection.On<string, byte[]>("IncomingNegotiation", IncomingNegotation);

At that point both sides of the chat have the shared secret, and can use it to encrypt and decrypt messages between each other. To round it off, let’s include the missing part of the UI, the loop through the connected users, which allows us to trigger the key exchange.

@foreach (var user in _users)
{
    <div>
        <dl>
            <dd>Username</dd>
            <dt>@user.Username</dt>
            <dd>Public Key</dd>
            <dt>@user.PublicKey.PrettyPrint()</dt>
            @if (user.NegotiatedSharedSecret == null)
            {
                <button type="button" class="btn btn-sm btn-primary ml-md-auto" @onclick="@(() => NegotiateSecret(user))">Establish secure session</button>
            }
            else
            {
                <dd>Shared Secret</dd>
                <dt>@user.NegotiatedSharedSecret.PrettyPrint()</dt>
                <hr />
                @foreach (var item in user.DirectMessages)
                {
                    <div>
                        <div><strong>@(item.Own ? _username : user.Username)</strong></div>
                        <div>@item.Body</div>
                    </div>
                }
                <div>
                    <textarea class="input-lg" placeholder="Direct encrypted message..." @bind="@_directMessage"></textarea><br />
                    <button type="button" class="btn btn-sm btn-primary ml-md-auto" @onclick="@(() => OutgoingDirectMessage(_directMessage, user))">Send</button>
                </div>
            }
        </dl>
    </div>
}

Notice that the way the UI is written, once the shared secret is established, the user can send direct messages to the other user (by invoking OutgoingDirectMessage). The message will be encrypted using the shared secret, and decrypted on the other side. This is the final missing piece, so let’s add it.

private async Task OutgoingDirectMessage(string message, User user)
{
    if (_isChatting && !string.IsNullOrWhiteSpace(message) && user.NegotiatedSharedSecret != null)
    {
        var encryptedMessage = AesHelper.Encrypt(Encoding.UTF8.GetBytes(message), user.NegotiatedSharedSecret);
        await _hubConnection.SendAsync("DirectMessage", user.Username, encryptedMessage);

        user.DirectMessages.Add(new Message(message, true));
        _directMessage = string.Empty;
    }
}

The method makes use of the AesHelper class, which is a simple wrapper around the BouncyCastle AES implementation.

public class AesHelper
{
    private const int _keySize = 256;
    private const int _macSize = 128;
    private const int _nonceSize = 128;
    private static readonly SecureRandom _random = new SecureRandom();

    public static byte[] Decrypt(byte[] encryptedMessage, byte[] key)
    {
        if (encryptedMessage == null || encryptedMessage.Length == 0)
        {
            throw new ArgumentNullException("encryptedMessage");
        }
        using var cipherStream = new MemoryStream(encryptedMessage);
        using var cipherReader = new BinaryReader(cipherStream);

        var nonce = cipherReader.ReadBytes(_nonceSize / 8);
        var cipher = new GcmBlockCipher(new AesEngine());
        var parameters = new AeadParameters(new KeyParameter(key), _macSize, nonce);
        cipher.Init(false, parameters);
        
        var cipherText = cipherReader.ReadBytes(encryptedMessage.Length - nonce.Length);
        var plainText = new byte[cipher.GetOutputSize(cipherText.Length)];
        var len = cipher.ProcessBytes(cipherText, 0, cipherText.Length, plainText, 0);
        cipher.DoFinal(plainText, len);
        
        return plainText;
    }

    public static byte[] Encrypt(byte[] messageToEncrypt, byte[] key)
    {
        var nonce = new byte[_nonceSize / 8];
        _random.NextBytes(nonce, 0, nonce.Length);

        var cipher = new GcmBlockCipher(new AesEngine());
        var parameters = new AeadParameters(new KeyParameter(key), _macSize, nonce);
        cipher.Init(true, parameters);
        
        var cipherText = new byte[cipher.GetOutputSize(messageToEncrypt.Length)];
        var len = cipher.ProcessBytes(messageToEncrypt, 0, messageToEncrypt.Length, cipherText, 0);
        cipher.DoFinal(cipherText, len);

        using (var combinedStream = new MemoryStream())
        {
            using (var binaryWriter = new BinaryWriter(combinedStream))
            {
                binaryWriter.Write(nonce);
                binaryWriter.Write(cipherText);
            }
            return combinedStream.ToArray();
        }
    }
}

A method on the SignalR is still need to act as a relay for the direct messages:

public async Task DirectMessage(string username, byte[] encryptedDirectMessage)
{
    if (!Users.TryGetValue(username, out var targetUser))
    {
        throw new Exception("Target user not found!");
    }

    var sourceUser = Users.FirstOrDefault(u => u.Value.ConnectionId == Context.ConnectionId);
    if (sourceUser.Value == null)
    {
        throw new Exception("Source user not found!");
    }
    
    await Clients.Client(targetUser.ConnectionId).SendAsync("IncomingDirectMessage", sourceUser.Value.Username, encryptedDirectMessage);
}

…and the client side has to handle the incoming direct messages, naturally making use of the shared secret to decrypt them with AES.

private void IncomingDirectMessage(string from, byte[] encryptedMessage)
{
    var user = _users.FirstOrDefault(u => u.Username == from);
    if (user == null)
    {
        Console.WriteLine($"ERROR: Received negotiation from unknown user '{from}'.");
        return;
    }

    var decryptedMessage = AesHelper.Decrypt(encryptedMessage, user.NegotiatedSharedSecret);
    user.DirectMessages.Add(new Message(Encoding.UTF8.GetString(decryptedMessage), false));
    InvokeAsync(StateHasChanged);
}

The handler must be registered with the hub connection:

_hubConnection.On<string, byte[]>("IncomingDirectMessage", IncomingDirectMessage);

Conclusion πŸ”—

And that’s it! We have a simple chat application that uses post-quantum cryptography for end-to-end encryption. At this point we can run two instances of the client app, and a single instance of the server, and try this out. It should give us an outcome similar to the one below:

The full source code for this demo can be found in the GitHub repository.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP