Dev stories: End-to-End Encrypted Chat with Rell

Dev stories: End-to-End Encrypted Chat with Rell

Perhaps you have participated in our alpha test program and tried out the social media app that we are developing with Rell on Chromia. If so, then you will now have something new to try out. We have created a prototype end-to-end (E2E) encrypted chat feature which creates a private communication channel inside a public blockchain, where normally everyone can read everything. To achieve this, each chat will have a shared secret key. This shared secret is created along with the chat and stored on the blockchain, encrypted with public key encryption for each member of the chat. Let’s look at the Rell architecture and code for this.

Rell Architecture

class chat {
    key id: text;
    mutable title: text;
    timestamp;
}

A chat has an ID, a title that is mutable, as well as a timestamp when it was created. The ID could actually be skipped, as a row_id is automatically added, but I prefer not to expose row_id externally. And there are also some benefits of the client knowing the ID prior to the chat being created.

Next up are the user classes. A user class already existed (and not of interest for this blog), but no public key was easily accessible; therefore, we introduce a chat_user class.

class chat_user {
    key user;
    rsa_pubkey: text;
}

The public key is deterministically generated from a passphrase that the user selected when signing up for the chat.

Now that we have a user for the chat, we can look at the chat membership.

class chat_membership {
    key chat, member: chat_user;
    encrypted_chat_key: text;
}

A  chat_membership is defined by a chat and a chat_user. An encrypted_chat_key is linked to it, which is a shared secret that is encrypted with the member’s public key. This means that when a new chat is created, a chat_membership is at the same time added by the chat founder, for the founder. After when anyone invites someone else to the chat, then they will retrieve the public key (rsa_pubkey) of the user that is to be invited and encrypts the shared secret in the new user’s membership (encrypted_chat_key).

A chat member can then easily decrypt the shared chat secret (encrypted_chat_key) by using his corresponding private RSA key.

Last but not least, a chat is not very interesting if there are no messages in it.

class chat_message {
    index chat;
    index sender: chat_user;
    index timestamp;
    encrypted_msg: text;
}

Here we have indexed a couple of fields to get better performance on our reads from the blockchain. Each message is stored encrypted with the shared chat secret, which we saw stored encrypted in the chat_membership.

So there we have the actual data that is persisted in the blockchain. Let’s look next at the needed operations.

Rell Operations

Let’s start by going through the process of creating a chat user and a chat.

operation create_chat_user(descriptor_id: byte_array, username: text, rsa_pubkey: text) {
    val user = get_verified_user(username, descriptor_id);
    create chat_user(user, rsa_pubkey);
}

Let’s ignore descriptor_id which is outside the scope of the actual chat, all we need to know for this blog is that it passed into a function get_verified_user that I’ve written which is used to retrieve the real user data and to make sure that the descriptor_id is allowed to perform operations in that user’s name. The descriptor_id is part of a library called ft3, which is used for the single-sign-on interaction with the Chromia Vault.

To create a chat_user, we need to provide a public key, which is used to encrypt shared chat keys. Keep in mind that a chat_user can have memberships in numerous chats.

We are now able to create a chat now that we have a chat user.

operation create_chat(chat_id: text, descriptor_id: byte_array, username: text, title: text, encrypted_chat_key: text) {

    val founder = get_verified_user(username, descriptor_id);
    
    val chat = create chat(
        id = chat_id,
        title = title,
        timestamp = op_context.last_block_time
    );

    create chat_membership(
        chat,
        chat_user@{ .user == founder },
        encrypted_chat_key
    );
}

After creating the chat, we also create a chat_membership at the same time. Note that the client created the chat key and encrypted it prior to the operation.

Encrypted chat on mobile

Now that we have a chat, we can then invite a user to it.

operation add_user_to_chat(descriptor_id: byte_array, username: text, chat_id: text, target_user: text, encrypted_chat_key: text) {
    val user_already_in_chat = get_verified_user(
        username,
        descriptor_id
    );

    val chat = chat@{ .id == chat_id };

    val previous_membership = chat_membership @? {
        chat,
        .member.user.name == target_user.lower_case()
    };

    if (previous_membership == null) {
        create chat_membership(
            chat,
            chat_user@{ .user.name == target_user.lower_case() },
            Encrypted_chat_key
        );
    }
}

Quite straightforward, too. We identify which chat to invite to by using the chat_id. Before this, the client has already retrieved the public key of the user to be invited and encrypted the shared chat secret with it.

So now, we have a chat with at least two participants, depending on how many users we added to the chat. Let’s send some messages, shall we?

operation send_chat_message(chat_id: text, descriptor_id: byte_array, username: name, encrypted_msg: text) {

    val user = get_verified_user(username, descriptor_id);

    val chat = chat@{ .id == chat_id };
    val chat_user = chat_user@{ user };
    val chat_member = chat_membership@?{ chat, chat_user };
    require(chat_member != null, app_error(
        error_type.USER_UNAUTHORIZED,
        “Only a chat member can send a message”
    ));

    create chat_message(
        chat,
        chat_user,
        op_context.last_block_time,
        encrypted_msg
    );
}

A chat member sends a message which is encrypted by the shared chat key, and now we have to persist that data. Notice the require statement that takes two arguments, a predicate which must be true, and a message  which will be visible if it isn't. We could have instead done chat_membership@{ chat, chat_user }, however, then it wouldn’t be as clear what went wrong.

Rell Queries

Many different queries can be written here, but let’s look at a few queries.

query get_chat_user_pubkey(username: name): text? {
    return chat_user@?{
    .user.name == username.lower_case()
    } ( .rsa_pubkey );
}

The first query retrieves an optional public key. The reason why it is optional is that the user may not have created a chat user yet.

query get_chat_messages(id: text, prior_to: timestamp, page_size: integer) {
    return chat_message@*{
        .chat.id == id,
        .timestamp < prior_to
    } (
        sender = chat_message.sender.user.display_name,
        -sort timestamp = chat_message.timestamp,
        encrypted_msg = chat_message.encrypted_msg
    ) limit page_size;
}

The second query retrieves messages before a timestamp. The response is limited by a page_size, and sorted by the timestamp in descending order.  The reason for this is so that we can paginate the response, fetch 100 messages, and then fetch the next 100, e.t.c.

query get_chat_participants(id: text) {
    return chat_membership@*{ .chat.id == id } (
    .member.user.display_name
    );
}

The third, and final, query list participants in a chat.

That’s it!

This is some of the Rell code running behind the new chat feature. It doesn’t have to be more complicated than that, and I hope that these real-world code examples demonstrate how easy it is to write Rell code.

About Viktor Plane

Software engineer and professional problem solver.

Comments