DEV Community

Gabriel
Gabriel

Posted on

A guide to build an encrypted email service on Solana with Anchor

Project overview

We will be building an end-to-end encrypted email service on the Solana blockchain. Let me give you a summary of how this program works:

The user registers himself on our dapp, he gets a Diffie-Hellmann key pair (a private and public key). Only the public key is stored on his newly created account on the blockchain.

And now, he can send encrypted emails to someone (who is also registered). Once the email is sent, both parties can decrypt the email locally with their private keys, this is secure because the encryption and decryption process is done locally, on the client, without any network request.

The blockchain is only used to store public information about the encryption, like the iv and salt, for example, and to register users.

We will use AES 256 bits with counter mode as our encryption algorithm. And elliptic curve Diffie-Hellmann for the key exchange.

Prerequisites

At least a basic understanding of Rust, and the following installed:

  1. Solana CLI
  2. Anchor framework

Anchor is a framework for Solana that makes our life much easier. It handles a lot of dirty work for us. Without it, we would have to do a lot of tedious things, like manual serialization and deserialization.

Configuration

Make sure that you generated a development keypair on the Solana CLI. And that it has enough SOL to pay for fees. Change the current network to the devnet solana config set --url devnet and airdrop some SOL with solana airdrop 2.

Now, let's create our project, open up your terminal and paste this command anchor init encrypted-mail. "encrypted-mail" is the name of the project, you can change this to any other name.

We need to install a few dependencies on the program, open the programs/encrypted-mail/Cargo.toml file, and append the following to the file:

[dependencies]
uuid = { version = "0.8.*", features = ["serde", "v5"] }
anchor-lang = "0.22.1"
Enter fullscreen mode Exit fullscreen mode

Open your program folder programs/encrypted-mail/src and add a few files to make your structure be exactly the same as this architecture:

...
├─ src
│  ├─ context.rs -> contexts of instructions
│  ├─ error.rs -> error structs
│  ├─ lib.rs -> contains all the instructions
│  ├─ state.rs -> state structs
│  ├─ utils.rs -> helpers functions
...
Enter fullscreen mode Exit fullscreen mode

Coding

Now that we have our foundation, let's start to do some code.

State.rs

Open your state.rs and paste the following:

use anchor_lang::prelude::*;

#[account]
pub struct Mail {
    pub from: Pubkey,
    pub to: Pubkey,
    pub id: String,
    pub subject: String,
    /* encrypted text */
    pub body: String,
    pub authority: Pubkey,
    pub created_at: u32,
    /* public information about encryption and decryption */
    pub iv: String,
    pub salt: String,
}

#[account]
pub struct UserAccount {
    /* pubkey from diffie helman exchange */
    pub diffie_pubkey: String,
    pub authority: Pubkey,
    pub bump: u8,
}

/* this event allows us to notify clients */
/* when a new email is created */
#[event]
pub struct NewEmailEvent {
    pub from: Pubkey,
    pub to: Pubkey,
    pub id: String,
}
Enter fullscreen mode Exit fullscreen mode

Solana stores data in accounts, and those structs are basically the types of our accounts, except for NewEmailEvent.

The iv and salt are generated when the client encrypt data. And it is also used to decrypt it, they are not sensitive data so we can store them on the blockchain without fear.

Context.rs

Open your context.rs file and paste the following:

use crate::state::{Mail, UserAccount};
use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct SendMail<'info> {
    #[account(
        init,
        payer = authority,
        space =
            8 +       // discriminator
            32 +      // from
            32 +      // to
            34 +      // id
            40 +      // subject
            512 +     // body
            32 +      // authority
            4 +       // created_at
            20 +      // salt
            36        // iv
    )]
    pub mail: Account<'info, Mail>,
    pub system_program: Program<'info, System>,
    #[account(mut)]
    pub authority: Signer<'info>,
}

#[derive(Accounts)]
pub struct Register<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        init,
        payer = authority,
        space =
            8  +          // discriminator
            4  + 64 +     // public key
            32 +          // authority
            1,            // bump
       seeds = [b"user-account", authority.key().as_ref()],
       bump
    )]
    pub user_account: Account<'info, UserAccount>,
    pub system_program: Program<'info, System>,
}
/* helper table for calculating accounts spaces */
/*
    bool            1 byte      1 bit rounded up to 1 byte.
    u8 or i8        1 byte
    u16 or i16      2 bytes
    u32 or i32      4 bytes
    u64 or i64      8 bytes
    u128 or i128    16 bytes
    [u16; 32]       64 bytes    32 items x 2 bytes. [itemSize; arrayLength]
    PubKey          32 bytes    Same as [u8; 32]
    vec<u16>        Any multiple of 2 bytes + 4 bytes for the prefix    Need to allocate the maximum amount of item that could be required.
    String          Any multiple of 1 byte + 4 bytes for the prefix Same as vec<u8>
*/
Enter fullscreen mode Exit fullscreen mode

Those structs are the context of the instructions. They hold and manage all the accounts that the instruction will interact.

An account will be managed by the #[account()] macro, you declare if the account is mutable or not, if it is a new account to be initialized with x amount of space, the constraints that it must obey etc. Please read the official documentation about this here.

An instruction is just a normal function that will be called on the client to interact with the program.

We have 2 instructions: register and send_email.

Error.rs

Open your error.rs file and paste the following:

use anchor_lang::prelude::*;

#[error_code]
pub enum ErrorCode {
    #[msg("Invalid instruction")]
    InvalidInstruction,

    #[msg("The body of your email is too long. The max is 512 chars")]
    InvalidBody,

    #[msg("The subject of your email is too long. The max is 40 chars")]
    InvalidSubject,

    #[msg("The salt should be exactly 16 chars")]
    InvalidSalt,

    #[msg("The IV should be exactly 32 chars")]
    InvalidIv,

    #[msg("The diffie publickey should be exactly 64 chars")]
    InvalidDiffie,
}
Enter fullscreen mode Exit fullscreen mode

This simply maps an error to a message.

Utils.rs

Open your utils.rs and paste the following:

use anchor_lang::prelude::Pubkey;
use uuid::Uuid;

/* creates a unique ID for a mail using now, body, and sender as arguments */
pub fn get_uuid(now: &u32, body: &String, sender: &Pubkey) -> String {
    const V5NAMESPACE: &Uuid = &Uuid::from_bytes([
        16, 92, 30, 120, 224, 152, 10, 207, 140, 56, 246, 228, 206, 99, 196, 138,
    ]);

    let now = now.to_be_bytes();
    let body = body.as_bytes();
    let sender = sender.to_bytes();

    let mut vec = vec![];

    vec.extend_from_slice(&now);
    vec.extend_from_slice(&body);
    vec.extend_from_slice(&sender);

    Uuid::new_v5(V5NAMESPACE, &vec).to_string()
}
Enter fullscreen mode Exit fullscreen mode

We have only one helper function, the get_uuid, this will take a few arguments and generate a unique id for each email.

Lib.rs

Open your lib.rs file and paste the following:

use {crate::error::ErrorCode, anchor_lang::prelude::*, context::*, utils::*};
pub mod context;
pub mod error;
pub mod state;
pub mod utils;

declare_id!("9KVS65SWuX5jnmJkzpyXMCdeKpad9G5sSoKopUUgDiA");

#[program]
pub mod encrypted-mail {
    use super::*;
    use anchor_lang::Key;

    pub fn send_email(
        ctx: Context<SendMail>,
        subject: String,
        body: String,
        from: Pubkey,
        to: Pubkey,
        salt: String,
        iv: String
    ) -> Result<()> {
        require!(subject.chars().count() < 50, ErrorCode::InvalidSubject);
        require!(body.chars().count() < 280, ErrorCode::InvalidBody);
        require!(salt.chars().count() == 16, ErrorCode::InvalidSalt);
        require!(iv.chars().count() == 32, ErrorCode::InvalidIv);

        let now = Clock::get().unwrap().unix_timestamp as u32;
        let mail = &mut ctx.accounts.mail;
        let id = get_uuid(&now, &body, &mail.key());

        mail.from = from;
        mail.to = to;
        mail.id = id.clone();
        mail.subject = subject;
        mail.body = body; // encrypted body, a ciphertext
        mail.created_at = now;
        mail.salt = salt;
        mail.iv = iv;
        mail.authority = *ctx.accounts.authority.key;

        emit!(state::NewEmailEvent {
            from,
            to,
            id
        });

        Ok(())
    }

pub fn register(ctx: Context<Register>, diffie_pubkey: String) -> Result<()> {
        require!(diffie_pubkey.chars().count() == 64, ErrorCode::InvalidDiffie);

        let user_account = &mut ctx.accounts.user_account;

        user_account.diffie_pubkey = diffie_pubkey;
        user_account.authority = *ctx.accounts.authority.key;
        user_account.bump = *ctx.bumps.get("user_account").unwrap();

        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

You need to change declare_id! with your account id, Anchor will output this information when you run anchor build.

The require! ensures that the user passes the right data to the instructions. If the condition is false, it will return an error.

The *ctx.bump.get("user_account") is an abstraction for generating a bump seed for this PDA, please read about PDA's here and here.

Now, that the program itself is done, we just need to make some tests on the client.

Testing

Add a few dependencies with this command: yarn add crypto-js elliptic text-encoding

Let's create a file /utils.ts at the root of the project, this file will contain abstractions to make readability easier on our tests. Paste the following into the file:

import { PublicKey } from "@solana/web3.js";
import { TextEncoder } from "text-encoding";
import idl from "./target/idl/minerva.json";
import { getProvider } from "@project-serum/anchor";
import { ec } from 'elliptic'

export const DEVNET_WALLET = getProvider().wallet.publicKey;

export const getUserPDA = async (
  seed: string,
  authority: PublicKey = DEVNET_WALLET
) => {
  const [PDA] = await PublicKey.findProgramAddress(
    [new TextEncoder().encode(seed), authority.toBuffer()],
    new PublicKey(idl.metadata.address)
  );
  return PDA;
};

export const elliptic = new ec('curve25519')
Enter fullscreen mode Exit fullscreen mode

The getUserPDA will generate a PDA for the user when he registers. We need to pass an array of seeds to generate the address.

The elliptic is just an instance of the curve25519 class that we imported from the elliptic library.

Open /tests/encrypted-mail.ts and let's start testing. You can erase everything and paste the following code:

import {
  Program,
  workspace,
  Provider,
  setProvider,
} from "@project-serum/anchor";
import AES from 'crypto-js/aes'
import { enc, mode, lib } from 'crypto-js'
import { Keypair, SystemProgram } from "@solana/web3.js";
import { EncryptedMail } from "../target/types/encrypted-mail";
import { DEVNET_WALLET, getUserPDA, elliptic } from "../utils";
import { expect } from "chai";

describe("beggining encrypted-mail tests", () => {
  setProvider(Provider.env());

  /* generating diffie helmann keys */
  const aliceKeypair = elliptic.genKeyPair()
  const bobKeypair = elliptic.genKeyPair()

  const aliceDiffiePublic = aliceKeypair.getPublic().encode("hex", true)
  const bobDiffiePublic = bobKeypair.getPublic().encode("hex", true)

  const sharedSecret = aliceKeypair.derive(bobKeypair.getPublic()).toString("hex")

  /* generating blockchain wallets */
  const alice = DEVNET_WALLET;
  const bob = Keypair.generate();

  const program = workspace.EncryptedMail as Program<EncryptedMail>;
}
Enter fullscreen mode Exit fullscreen mode

We generate 2 key pairs, one for Alice, and one for Bob. We can generate a shared private key from the private of one of them, and the public of the other. It is the shared private that will be used to encrypt and decrypt the emails.

Let's add our first test, append the following code inside your describe function:

it("can register alice and bob", async () => {
    const aliceAccountPDA = await getUserPDA("user-account");
    const bobAccountPDA = await getUserPDA("user-account", bob.publicKey);

    const airdropTx = await program.provider.connection.requestAirdrop(
      bob.publicKey,
      1000000000
    );

    await program.provider.connection.confirmTransaction(airdropTx);

    await program.rpc.register(aliceDiffiePublic, {
      accounts: {
        authority: alice,
        userAccount: aliceAccountPDA,
        systemProgram: SystemProgram.programId,
      },
    });

    await program.rpc.register(bobDiffiePublic, {
      accounts: {
        authority: bob.publicKey,
        userAccount: bobAccountPDA,
        systemProgram: SystemProgram.programId,
      },
      signers: [bob]
    });

    const users = await program.account.userAccount.all();

    console.log("users: ", users);
    expect(users.length).to.equal(2);
  });
Enter fullscreen mode Exit fullscreen mode

This is very straightforward, the function start by generating the PDA for Alice and Bob and then calls the register instruction from the program, and then we get all the userAccounts and check to see if they equal 2.

Finally, the last test is to encrypt the email, send the email, and decrypt it back.

it("can encrypt emails, send the emails, and decrypt it", async () => {
    const mailA = Keypair.generate();

    let cipher = AES.encrypt("simplesmente intankavel o bostil", sharedSecret, { mode: mode.CTR })

    await program.rpc.sendEmail(
      "very important subject", // subject
      cipher.ciphertext.toString(), // body of email
      alice, // from
      bob.publicKey, // to
      cipher.salt.toString(), // salt
      cipher.iv.toString(), // iv
      {
        accounts: {
          authority: alice,
          mail: mailA.publicKey,
          systemProgram: SystemProgram.programId,
        },
        signers: [mailA],
      }
    );

    const emails = await program.account.mail.all();

    const email = emails[0].account

    const plaintext = AES.decrypt(
      {
        ciphertext: enc.Hex.parse(email.body),
        iv: enc.Hex.parse(email.iv),
        salt: enc.Hex.parse(email.salt)
      } as lib.CipherParams,
      sharedSecret,
      { mode: mode.CTR }
    )

    console.log("\n");
    console.log("emails: ", emails);
    console.log("\n");
    console.log('plaintext: ', plaintext.toString(enc.Utf8))
    console.log("shared_secret: ", sharedSecret);
    console.log("cyphertext: ", emails[0].account.body);
    console.log("\n");

    expect(plaintext.toString(enc.Utf8)).to.equal('simplesmente intankavel o bostil');
  });
Enter fullscreen mode Exit fullscreen mode

First, we generate an email account, then encrypt the body of the email, and call the sendEmail instruction.

After that, we get the email back and decrypt the body of the email. And check to see if the decrypted message is the same message that was encrypted.

To run the tests, first, you need to build the program with anchor build. At the end of the build, Anchor will print your program id on your terminal, you need to copy this and replace on declare_id! macro on lib.rs and also on /Anchor.toml. This file should look like this:

[programs.localnet]
encrypted-mail = "9KVS65SWuX5jnmJkzpyXMCdeKpad9G5sSoKopUUgDiA"

[programs.devnet]
encrypted-mail = "9KVS65SWuX5jnmJkzpyXMCdeKpad9G5sSoKopUUgDiA"

[registry]
url = "https://anchor.projectserum.com"

[provider]
cluster = "localnet"
wallet = "~/.config/solana/devnet.json"

[scripts]
test = "npx ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
Enter fullscreen mode Exit fullscreen mode

Now you can run anchor deploy and anchor test. And that's it!

The end

I could not dive into every technical aspect of Solana in detail, because there would be just too much to be explained here, I prefer to focus on a more practical approach at first, you should read the official documentation of Anchor and Solana to understand better the theory behind all of this.

If you made it this far, congratulations! If you got lost at some point, you can check the source code here. This is my finished frontend/dapp of this program, make sure you use your wallet on the devnet. Also, feel free to send me a message if you have any questions.

Top comments (0)